Generated banner for Review Walkthrough
Generated logo for Review Walkthrough

Review Walkthrough

Trail of BitsOfficialPublished by Trail of Bits

  • Skill
  • Content generator
  • Software development
  • CC-BY-SA-4.0
  • Low risk
  • Has scripts

From the author

Generates an interactive HTML walkthrough for reviewing code changes. Use only when explicitly called.
— the description by Trail of Bits

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

The review-walkthrough skill creates a self-contained, interactive HTML document that helps developers understand complex code changes. It captures the diff between a branch and its base, organizes the changes into logical, concept-based steps, and provides detailed explanations and review findings for each. The resulting artifact is designed for clear communication, allowing reviewers to navigate through code changes in a structured way that prioritizes comprehension over raw commit order.

What it does

  • Captures a complete git diff between a branch and a specified base.
  • Groups related file changes into logical steps for better readability.
  • Generates HTML explanations for each step, detailing purpose and design choices.
  • Identifies and documents review findings such as bugs, security issues, or performance concerns.
  • Anchors findings to specific lines in the diff for precise feedback.
  • Renders a final, interactive HTML walkthrough file for browser-based review.

When to use it

  • When you need to present a complex set of changes to a team for review.
  • When you want to provide context and design rationale alongside code diffs.
  • When you need to highlight specific security or performance findings within a PR.
  • When you want to improve the quality and efficiency of the code review process.

Advantages

  • Improves comprehension by organizing changes by concept rather than commit order.
  • Provides a structured format for documenting design decisions and trade-offs.
  • Enables precise, anchored feedback on specific lines of code.
  • Creates a professional, shareable artifact for team collaboration.

Usage examples

“/review-walkthrough:review-walkthrough”
The agent analyzes the current branch, generates a structured HTML walkthrough of the changes, and provides the path to the resulting file.

Install

  1. Download the skill as a ZIP:

    https://mp-staging.autogineer.com/agent-skills/trailofbits/review-walkthrough/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip review-walkthrough.zip -d .agents/skills

Documentation by Trail of Bits

Review Walkthrough

Generate a self-contained HTML walkthrough of the current branch, with the final diff split into logical steps, explanations, and critical review findings. Choose the grouping and reading order for comprehension; commit order need not dictate either. Invoke as /review-walkthrough:review-walkthrough in Claude Code or $review-walkthrough in Codex.

Capture the branch diff

Respect a user-specified base. Otherwise, use the current open PR's base when PR metadata is available. Without a PR, discover the repository's default branch from git symbolic-ref --quiet --short refs/remotes/origin/HEAD or the configured remote's equivalent. Do not assume it is main. If the selected base is missing or ambiguous, ask for it rather than silently choosing another branch.

Resolve the base and HEAD to commit IDs with git rev-parse --verify --end-of-options "${ref}^{commit}", then compute their merge base with git merge-base. Capture the complete patch using git diff --no-ext-diff --no-textconv --no-color --src-prefix=a/ --dst-prefix=b/ "$merge_base" "$head_sha" --. Initialize variables and run the commands that consume them in the same shell invocation; shell variables do not persist between tool calls. Save the patch to a temporary file outside the checkout and retain its actual path. Stop on a Git error or an empty patch and explain the result.

This scope includes committed changes through the captured HEAD. Mention any uncommitted work that is excluded. Do not change branches, reset the checkout, or alter source files to prepare the review. Keep generated data and HTML outside the checkout unless the user requests a particular output location; generated review artifacts never belong in the captured patch.

For a GitHub PR, collect owner, repo, pr_number, and head_sha from its metadata. Include them only when the PR head matches the captured HEAD and the chosen base matches the PR base. Otherwise use null and explain why comment export is unavailable. A failed authentication or network request is not evidence that no PR exists; report the failure and ask for the base if it cannot be determined. GitHub access is optional when the user supplies the base or confirms there is no PR.

Shape the review

Read the changed files and the surrounding source, tests, configuration, and dependency metadata needed to judge them. Keep the review read-only; do not run the project's tests or install its dependencies merely to build the walkthrough.

Group related files into steps covering one concept each. Put definitions before their consumers, wiring after the components it connects, and tests after or beside their subjects. Copy each complete per-file diff from the captured patch verbatim into exactly one step. Reordering files between steps is allowed; splitting, rewriting, omitting, or inventing their patches is not. Preserve rename, binary, and mode-change entries even when they have no text hunks. Binary summaries identify changed files; their payloads are outside this review.

Write one explanation per step, usually 50–150 words, covering the change's purpose, design choices, trade-offs, and connections to other steps. Refer to actual identifiers. Write one review list per step covering bugs, security, validation, API design, or performance issues supported by the code. Report findings with severity rather than suppressing minor issues. Use an empty list for a step with no findings.

Explanations and review bodies are HTML fragments. Use <p>, <strong>, <em>, <code>, <pre>, and <ul>/<li> for structure. Write literal < and > in prose or snippets as < and >. Backticks and Markdown fences are literal text here. Supported inline tags also include <sup>, <sub>, <kbd>, <del>, and <a href="https://…">. The page sanitizes fragments and converts them to Markdown when preparing a PR comment. Only safe link targets survive; arbitrary attributes, scripts, and images do not. Tables export as text rows with cell separators; captions and row order are preserved. Prefer prose, lists, and code blocks when their structure is sufficient.

Anchor findings to a file in the same step and a line in that file's displayed diff. Use side: "RIGHT" for additions or new-file context and side: "LEFT" for deletions or old-file context. For a range, line and end_line use that same side within one hunk. A finding spanning multiple steps belongs with the file it addresses, or remains unanchored. An unanchored finding is still displayed but cannot become an inline PR comment.

Render the artifact

Use a file-writing tool to create a JSON object with the fields below. Preserve the literal patch as JSON string data, with no shell expansion.

FieldValue
titleFeature or review title
stepsArray of objects with sha (step ID such as step-1), message (step title), files (paths in patch order), and diff (complete per-file patches)
explanationsHTML strings, one per step
reviewsArrays of findings, one per step; each finding has severity (high, medium, or low), title, and HTML body
pr_metaObject with owner, repo, pr_number, and head_sha, or null

An anchored finding also has file, line, and side, plus optional end_line. The three arrays have equal lengths and corresponding entries. File paths match the diff headers without their a/ or b/ prefix; a renamed file uses its new path. Set side explicitly so lines that occur on both sides are unambiguous.

Run the bundled renderer with the actual paths created for this review:

uv run --no-project {baseDir}/scripts/render_walkthrough.py \
  --input /tmp/data.json \
  --diff /tmp/captured.patch \
  --output /tmp/walkthrough.html

The renderer validates the data, compares every step's patches with the complete captured diff, checks anchors, and safely embeds the result in the bundled template. Fix reported input errors rather than bypassing the renderer or generating an alternative page.

Open the output with open on macOS or xdg-open on Linux when a browser is available, and tell the user its path. In a headless run, report the file without opening it. When PR metadata is present, the page lets the user copy a gh api command for their selected comments. It does not execute the command or post anything to GitHub. Comment and Request Changes require a review summary before the command can be copied; the summary is optional for Approve.

Files

template.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>TITLE_PLACEHOLDER - Code Review Walkthrough</title>
<style>
  :root {
    --bg: #0d1117;
    --bg-secondary: #161b22;
    --bg-tertiary: #1c2128;
    --border: #30363d;
    --text: #e6edf3;
    --text-muted: #8b949e;
    --accent: #58a6ff;
    --accent-dim: #1f6feb33;
    --green: #3fb950;
    --green-bg: #12261e;
    --red: #f85149;
    --red-bg: #2d1214;
    --line-num: #484f58;
    --progress-bg: #21262d;
    --tag-bg: #1f6feb22;
    --yellow: #d29922;
  }

  * { margin: 0; padding: 0; box-sizing: border-box; }

  body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
    background: var(--bg);
    color: var(--text);
    height: 100vh;
    display: flex;
    flex-direction: column;
    overflow: hidden;
  }

  header {
    background: var(--bg-secondary);
    border-bottom: 1px solid var(--border);
    padding: 12px 20px;
    flex-shrink: 0;
  }

  .header-top {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 10px;
  }

  .header-top h1 { font-size: 16px; font-weight: 600; }
  .step-counter { font-size: 13px; color: var(--text-muted); font-weight: 500; }

  .progress-bar { display: flex; gap: 4px; height: 4px; }

  .progress-segment {
    flex: 1;
    background: var(--progress-bg);
    border-radius: 2px;
    transition: background 0.3s;
    cursor: pointer;
  }

  .progress-segment.visited { background: var(--accent); opacity: 0.5; }
  .progress-segment.active { background: var(--accent); opacity: 1; }

  .main { display: flex; flex: 1; overflow: hidden; }

  .diff-pane {
    flex: 1;
    overflow-y: auto;
    border-right: 1px solid var(--border);
    font-family: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace;
    font-size: 12px;
    line-height: 20px;
  }

  .diff-file { margin-bottom: 2px; }

  .diff-file-header {
    background: var(--bg-tertiary);
    padding: 8px 16px;
    font-weight: 600;
    font-size: 12px;
    color: var(--text-muted);
    border-bottom: 1px solid var(--border);
    border-top: 1px solid var(--border);
    position: sticky;
    top: 0;
    z-index: 1;
  }

  .diff-hunk-header {
    background: var(--accent-dim);
    color: var(--accent);
    padding: 4px 16px;
    font-size: 11px;
  }

  .diff-line {
    padding: 0 16px;
    white-space: pre-wrap;
    word-break: break-all;
    display: flex;
  }

  .diff-line .line-num {
    color: var(--line-num);
    min-width: 50px;
    text-align: right;
    padding-right: 16px;
    user-select: none;
    flex-shrink: 0;
    position: relative;
  }

  .diff-line .line-content { flex: 1; min-width: 0; }
  .diff-line.addition { background: var(--green-bg); color: var(--green); }
  .diff-line.deletion { background: var(--red-bg); color: var(--red); }
  .diff-line.context { color: var(--text-muted); }

  /* Line commenting — + button */
  .line-add-btn {
    display: none;
    position: absolute;
    left: 2px;
    top: 0;
    width: 20px;
    height: 20px;
    background: #1f6feb;
    color: #fff;
    border: none;
    border-radius: 3px;
    cursor: pointer;
    font-size: 14px;
    font-weight: 700;
    line-height: 20px;
    text-align: center;
    z-index: 2;
  }

  .line-add-btn:hover { background: #388bfd; }

  .commenting-enabled .diff-line:hover .line-add-btn {
    display: block;
  }

  /* Hide + button on lines that are already selected or have form open */
  .commenting-enabled .diff-line.selected .line-add-btn { display: none; }

  /* Hide add buttons during line selection. */
  .commenting-enabled.dragging .diff-line .line-add-btn { display: none; }

  /* Prevent text selection during drag */
  .dragging { user-select: none; }

  /* Line selection highlight */
  .diff-line.selected {
    background: rgba(31, 111, 235, 0.15) !important;
  }
  .diff-line.selected .line-content { color: var(--text) !important; }

  /* Comment marker for lines with pending comments */
  .diff-line.has-comment .line-num::after {
    content: '';
    position: absolute;
    right: 6px;
    top: 7px;
    width: 6px;
    height: 6px;
    background: var(--accent);
    border-radius: 50%;
  }

  /* Inline comment form */
  .inline-comment-form {
    background: var(--bg-secondary);
    border: 1px solid var(--border);
    border-radius: 6px;
    margin: 4px 16px 8px 16px;
    overflow: hidden;
  }

  .comment-form-header {
    padding: 8px 12px;
    font-size: 11px;
    color: var(--text-muted);
    border-bottom: 1px solid var(--border);
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
  }

  .comment-form-header code {
    font-family: "SF Mono", Menlo, monospace;
    font-size: 11px;
    color: var(--accent);
  }

  .inline-comment-form textarea {
    width: 100%;
    min-height: 80px;
    padding: 10px 12px;
    background: var(--bg);
    color: var(--text);
    border: none;
    border-bottom: 1px solid var(--border);
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
    font-size: 13px;
    line-height: 1.5;
    resize: vertical;
    outline: none;
  }

  .inline-comment-form textarea:focus {
    box-shadow: inset 0 0 0 1px var(--accent);
  }

  .inline-comment-form textarea::placeholder { color: var(--text-muted); }

  .comment-form-actions {
    display: flex;
    justify-content: flex-end;
    gap: 8px;
    padding: 8px 12px;
  }

  .comment-form-actions button {
    padding: 5px 14px;
    border-radius: 5px;
    font-size: 12px;
    font-weight: 500;
    cursor: pointer;
    border: 1px solid var(--border);
    transition: all 0.15s;
  }

  .btn-cancel {
    background: transparent;
    color: var(--text-muted);
  }

  .btn-cancel:hover { color: var(--text); border-color: var(--text-muted); }

  .btn-add-comment {
    background: var(--accent);
    color: #000;
    border-color: var(--accent);
    font-weight: 600;
  }

  .btn-add-comment:hover { opacity: 0.9; }
  .btn-add-comment:disabled { opacity: 0.4; cursor: not-allowed; }

  .explanation-pane {
    width: 420px;
    flex-shrink: 0;
    overflow-y: auto;
    padding: 24px;
    display: flex;
    flex-direction: column;
  }

  .commit-title {
    font-size: 18px;
    font-weight: 700;
    margin-bottom: 6px;
    line-height: 1.3;
  }

  .commit-sha {
    font-family: "SF Mono", Menlo, monospace;
    font-size: 12px;
    color: var(--accent);
    margin-bottom: 16px;
  }

  .file-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 16px; }

  .file-tag {
    font-size: 11px;
    font-family: "SF Mono", Menlo, monospace;
    padding: 2px 8px;
    background: var(--tag-bg);
    color: var(--accent);
    border-radius: 4px;
    border: 1px solid var(--border);
  }

  .tab-bar {
    display: flex;
    gap: 0;
    margin-bottom: 16px;
    border-bottom: 1px solid var(--border);
  }

  .tab-btn {
    padding: 8px 16px;
    font-size: 13px;
    font-weight: 500;
    color: var(--text-muted);
    background: none;
    border: none;
    border-bottom: 2px solid transparent;
    cursor: pointer;
    transition: all 0.15s;
  }

  .tab-btn:hover { color: var(--text); }
  .tab-btn.active { color: var(--text); border-bottom-color: var(--accent); }

  .tab-content { display: none; font-size: 14px; line-height: 1.7; flex: 1; overflow-y: auto; }
  .tab-content.active { display: block; }

  .explanation { font-size: 14px; line-height: 1.7; flex: 1; }
  .explanation p { margin-bottom: 12px; }
  .explanation strong { color: var(--accent); font-weight: 600; }

  .explanation code {
    font-family: "SF Mono", Menlo, monospace;
    font-size: 12px;
    background: var(--bg-tertiary);
    padding: 2px 6px;
    border-radius: 3px;
    border: 1px solid var(--border);
  }

  .explanation ul, .explanation ol { margin-bottom: 12px; padding-left: 20px; }
  .explanation li { margin-bottom: 4px; }

  .explanation pre {
    font-family: "SF Mono", Menlo, monospace;
    font-size: 12px;
    line-height: 1.5;
    background: var(--bg-tertiary);
    border: 1px solid var(--border);
    border-radius: 4px;
    padding: 8px 10px;
    margin: 8px 0;
    overflow-x: auto;
  }

  /* A <code> inside <pre> should not get the inline pill treatment. */
  .explanation pre code {
    background: none;
    border: none;
    padding: 0;
    font-size: inherit;
  }

  .review-item { margin-bottom: 16px; padding: 12px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg-secondary); }
  .review-item.severity-high { border-left: 3px solid var(--red); }
  .review-item.severity-medium { border-left: 3px solid var(--yellow); }
  .review-item.severity-low { border-left: 3px solid var(--text-muted); }
  .review-item .review-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
  .review-severity { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
  .review-severity.high { color: var(--red); }
  .review-severity.medium { color: var(--yellow); }
  .review-severity.low { color: var(--text-muted); }
  .review-item p { margin-bottom: 8px; }
  .review-item p:last-child { margin-bottom: 0; }
  .review-body { margin-bottom: 8px; }
  .review-body > pre:last-child { margin-bottom: 0; }
  .review-no-issues { color: var(--green); font-style: italic; }

  .review-item-footer {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-top: 8px;
  }

  .review-item-file {
    font-size: 12px;
    color: var(--text-muted);
    text-decoration: none;
  }

  .review-item-file:hover { color: var(--accent); }

  .review-item-file code {
    font-family: "SF Mono", Menlo, monospace;
    font-size: 11px;
  }

  .btn-convert-comment {
    padding: 3px 10px;
    font-size: 11px;
    font-weight: 500;
    color: var(--accent);
    background: none;
    border: 1px solid var(--border);
    border-radius: 4px;
    cursor: pointer;
    transition: all 0.15s;
    white-space: nowrap;
  }

  .btn-convert-comment:hover {
    background: var(--accent-dim);
    border-color: var(--accent);
  }

  .btn-convert-comment:disabled {
    opacity: 0.3;
    cursor: not-allowed;
  }

  /* Inline user comments in diff pane */
  .inline-user-comment {
    margin: 0;
    padding: 10px 16px 10px 20px;
    background: var(--bg-secondary);
    border-top: 1px solid var(--border);
    border-bottom: 1px solid var(--border);
    border-left: 3px solid var(--accent);
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
    font-size: 13px;
    line-height: 1.5;
    display: flex;
    align-items: flex-start;
    gap: 8px;
  }

  .inline-user-comment-body {
    flex: 1;
    min-width: 0;
  }

  .inline-user-comment-label {
    font-size: 11px;
    font-weight: 600;
    color: var(--accent);
    margin-bottom: 2px;
  }

  /* Rendered Markdown, shared by the diff overlay and the comments list. */
  .comment-md { color: var(--text); word-break: break-word; }
  .comment-md p { margin: 0 0 6px 0; }
  .comment-md p:last-child { margin-bottom: 0; }
  .comment-md strong { font-weight: 600; }

  .comment-md code {
    font-family: "SF Mono", Menlo, monospace;
    font-size: 12px;
    background: var(--bg-tertiary);
    padding: 1px 4px;
    border-radius: 3px;
    border: 1px solid var(--border);
  }

  .comment-md pre {
    font-family: "SF Mono", Menlo, monospace;
    font-size: 12px;
    line-height: 1.5;
    background: var(--bg-tertiary);
    border: 1px solid var(--border);
    border-radius: 4px;
    padding: 6px 8px;
    margin: 6px 0;
    overflow-x: auto;
  }

  .comment-md pre code {
    background: none;
    border: none;
    padding: 0;
    font-size: inherit;
  }

  .inline-user-comment-actions {
    display: flex;
    gap: 2px;
    flex-shrink: 0;
  }

  .inline-user-comment-actions button {
    background: none;
    border: none;
    color: var(--text-muted);
    cursor: pointer;
    font-size: 14px;
    padding: 0 4px;
    border-radius: 3px;
    line-height: 1;
  }

  .inline-user-comment-actions button:hover { background: var(--bg-tertiary); }
  .inline-user-comment-actions .btn-edit:hover { color: var(--accent); }
  .inline-user-comment-actions .btn-delete:hover { color: var(--red); }

  .inline-user-comment textarea {
    width: 100%;
    min-height: 60px;
    padding: 6px 8px;
    background: var(--bg);
    color: var(--text);
    border: 1px solid var(--accent);
    border-radius: 4px;
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
    font-size: 13px;
    line-height: 1.5;
    resize: vertical;
    outline: none;
  }

  .inline-user-comment .edit-actions {
    display: flex;
    justify-content: flex-end;
    gap: 6px;
    margin-top: 6px;
  }

  .inline-user-comment .edit-actions button {
    padding: 4px 12px;
    border-radius: 4px;
    font-size: 12px;
    font-weight: 500;
    cursor: pointer;
    border: 1px solid var(--border);
  }

  .inline-user-comment .edit-actions .btn-save {
    background: var(--accent);
    color: #000;
    border-color: var(--accent);
    font-weight: 600;
  }

  .inline-user-comment .edit-actions .btn-save:hover { opacity: 0.9; }

  .inline-user-comment .edit-actions .btn-edit-cancel {
    background: transparent;
    color: var(--text-muted);
  }

  .inline-user-comment .edit-actions .btn-edit-cancel:hover { color: var(--text); }

  /* Comment badge on tab */
  .comment-badge {
    display: inline-block;
    background: var(--accent);
    color: #000;
    font-size: 10px;
    font-weight: 700;
    min-width: 16px;
    height: 16px;
    line-height: 16px;
    text-align: center;
    border-radius: 8px;
    margin-left: 6px;
    padding: 0 5px;
  }

  /* Pending comments list in Comments tab */
  .pending-comment-item {
    margin-bottom: 12px;
    padding: 12px;
    border-radius: 6px;
    border: 1px solid var(--border);
    background: var(--bg-secondary);
    position: relative;
  }

  .pending-comment-file {
    display: block;
    font-family: "SF Mono", Menlo, monospace;
    font-size: 11px;
    color: var(--accent);
    margin-bottom: 4px;
    text-decoration: none;
  }

  .pending-comment-file:hover { text-decoration: underline; }

  .pending-comment-body {
    font-size: 13px;
    line-height: 1.5;
    color: var(--text);
    word-break: break-word;
  }

  .pending-comment-delete {
    position: absolute;
    top: 8px;
    right: 8px;
    background: none;
    border: none;
    color: var(--text-muted);
    cursor: pointer;
    font-size: 14px;
    padding: 2px 6px;
    border-radius: 3px;
  }

  .pending-comment-delete:hover { color: var(--red); background: var(--bg-tertiary); }

  .comments-empty {
    color: var(--text-muted);
    font-size: 13px;
    font-style: italic;
    margin-bottom: 16px;
  }

  /* Submit review section */
  .submit-section {
    margin-top: 16px;
    padding-top: 16px;
    border-top: 1px solid var(--border);
  }

  .submit-section label {
    display: block;
    font-size: 12px;
    color: var(--text-muted);
    margin-bottom: 6px;
    font-weight: 500;
  }

  .submit-section textarea {
    width: 100%;
    min-height: 50px;
    padding: 8px 10px;
    background: var(--bg);
    color: var(--text);
    border: 1px solid var(--border);
    border-radius: 5px;
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
    font-size: 13px;
    line-height: 1.5;
    resize: vertical;
    outline: none;
    margin-bottom: 10px;
  }

  .submit-section textarea:focus { border-color: var(--accent); }
  .submit-section textarea::placeholder { color: var(--text-muted); }

  .submit-section select {
    width: 100%;
    padding: 7px 10px;
    background: var(--bg);
    color: var(--text);
    border: 1px solid var(--border);
    border-radius: 5px;
    font-size: 13px;
    margin-bottom: 12px;
    outline: none;
    cursor: pointer;
  }

  .submit-section select:focus { border-color: var(--accent); }

  .btn-copy-command {
    width: 100%;
    padding: 10px 16px;
    background: var(--accent);
    color: #000;
    border: none;
    border-radius: 6px;
    font-size: 14px;
    font-weight: 600;
    cursor: pointer;
    transition: opacity 0.15s;
  }

  .btn-copy-command:hover { opacity: 0.9; }
  .btn-copy-command:disabled { opacity: 0.4; cursor: not-allowed; }

  .gh-command-box {
    margin-top: 10px;
    background: var(--bg);
    border: 1px solid var(--border);
    border-radius: 5px;
    padding: 10px 12px;
    font-family: "SF Mono", Menlo, monospace;
    font-size: 11px;
    line-height: 1.5;
    color: var(--text-muted);
    white-space: pre-wrap;
    word-break: break-all;
    max-height: 200px;
    overflow-y: auto;
    display: none;
  }

  .gh-command-box.visible { display: block; }

  /* Toast notification */
  .toast {
    position: fixed;
    bottom: 24px;
    left: 50%;
    transform: translateX(-50%) translateY(20px);
    background: var(--accent);
    color: #000;
    padding: 10px 20px;
    border-radius: 6px;
    font-size: 13px;
    font-weight: 600;
    opacity: 0;
    transition: all 0.3s ease;
    pointer-events: none;
    z-index: 100;
  }

  .toast.show {
    opacity: 1;
    transform: translateX(-50%) translateY(0);
  }

  .nav-buttons { display: flex; gap: 10px; margin-top: 20px; flex-shrink: 0; }

  .nav-btn {
    flex: 1;
    padding: 10px 16px;
    border: 1px solid var(--border);
    border-radius: 6px;
    background: var(--bg-secondary);
    color: var(--text);
    font-size: 14px;
    font-weight: 500;
    cursor: pointer;
    transition: all 0.15s;
  }

  .nav-btn:hover:not(:disabled) { background: var(--bg-tertiary); border-color: var(--accent); }

  .nav-btn.primary {
    background: var(--accent);
    color: #000;
    border-color: var(--accent);
    font-weight: 600;
  }

  .nav-btn.primary:hover { opacity: 0.9; background: var(--accent); }
  .nav-btn:disabled { opacity: 0.3; cursor: not-allowed; }

  .kbd-hint { text-align: center; font-size: 11px; color: var(--text-muted); margin-top: 8px; }

  /* Top-level tabs (Step / Comments) */
  .top-tab-bar {
    display: flex;
    gap: 0;
    border-bottom: 1px solid var(--border);
    margin-bottom: 16px;
    flex-shrink: 0;
  }

  .top-tab-btn {
    padding: 10px 16px;
    font-size: 14px;
    font-weight: 600;
    color: var(--text-muted);
    background: none;
    border: none;
    border-bottom: 2px solid transparent;
    cursor: pointer;
    transition: all 0.15s;
  }

  .top-tab-btn:hover { color: var(--text); }
  .top-tab-btn.active { color: var(--text); border-bottom-color: var(--accent); }

  .top-tab-pane {
    display: none;
    flex-direction: column;
    flex: 1;
    overflow-y: auto;
    min-height: 0;
  }

  .top-tab-pane.active {
    display: flex;
  }

  kbd {
    font-family: "SF Mono", Menlo, monospace;
    background: var(--bg-tertiary);
    border: 1px solid var(--border);
    padding: 1px 5px;
    border-radius: 3px;
    font-size: 11px;
  }

  ::-webkit-scrollbar { width: 8px; }
  ::-webkit-scrollbar-track { background: transparent; }
  ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
  ::-webkit-scrollbar-thumb:hover { background: var(--line-num); }
</style>
</head>
<body>

<header>
  <div class="header-top">
    <h1>TITLE_PLACEHOLDER</h1>
    <span class="step-counter" id="stepCounter"></span>
  </div>
  <div class="progress-bar" id="progressBar"></div>
</header>

<div class="main">
  <div class="diff-pane" id="diffPane"></div>
  <div class="explanation-pane">
    <div class="top-tab-bar" id="topTabBar">
      <button class="top-tab-btn active" id="topTabStep" onclick="switchTopTab('step')">Step</button>
      <button class="top-tab-btn" id="topTabComments" onclick="switchTopTab('comments')" style="display:none">
        Comments <span class="comment-badge" id="commentBadge" style="display:none">0</span>
      </button>
    </div>
    <div class="top-tab-pane active" id="paneStep">
      <div class="commit-title" id="commitTitle"></div>
      <div class="commit-sha" id="stepLabel"></div>
      <div class="file-tags" id="fileTags"></div>
      <div class="tab-bar" id="tabBar">
        <button class="tab-btn active" id="tabExplain" onclick="switchTab('explain')">Explanation</button>
        <button class="tab-btn" id="tabReview" onclick="switchTab('review')">Review</button>
      </div>
      <div class="tab-content active" id="contentExplain">
        <div class="explanation" id="explanation"></div>
      </div>
      <div class="tab-content" id="contentReview">
        <div class="explanation" id="review"></div>
      </div>
      <div class="nav-buttons">
        <button class="nav-btn" id="prevBtn" onclick="navigate(-1)">Previous</button>
        <button class="nav-btn primary" id="nextBtn" onclick="navigate(1)">Next</button>
      </div>
      <div class="kbd-hint"><kbd>&larr;</kbd> <kbd>&rarr;</kbd> to navigate</div>
    </div>
    <div class="top-tab-pane" id="paneComments">
      <div id="commentsList"></div>
      <div class="submit-section" id="submitSection">
        <label for="reviewBody" id="reviewBodyLabel">Review summary (required)</label>
        <textarea id="reviewBody" placeholder="Overall review comment..." required></textarea>
        <label for="reviewEvent">Review action</label>
        <select id="reviewEvent">
          <option value="COMMENT">Comment</option>
          <option value="APPROVE">Approve</option>
          <option value="REQUEST_CHANGES">Request Changes</option>
        </select>
        <button class="btn-copy-command" id="btnCopyCommand" onclick="copyReviewCommand()" disabled>
          Copy gh command to clipboard
        </button>
        <div class="gh-command-box" id="ghCommandBox"></div>
      </div>
    </div>
  </div>
</div>

<div class="toast" id="toast"></div>

<script>
const STEPS = STEPS_PLACEHOLDER;
const EXPLANATIONS = EXPLANATIONS_PLACEHOLDER;
const REVIEWS = REVIEWS_PLACEHOLDER;
const PR_META = PR_META_PLACEHOLDER;

let current = 0;
let activeTab = 'explain';
let pendingComments = [];
let selectionStart = null; // {el, file, line, side}
let commentFormEl = null;

function escape(s) {
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

// Tags a review body or explanation is allowed to keep: everything the comment
// converter handles, plus the block markup that only ever renders on the page,
// minus <img>. Nothing here loads or runs anything, so <img>,
// <iframe>, <object> and <script> have no reason to be in a self-contained page of
// text about code.
var FRAGMENT_TAGS_OK =
  /^(?:code|pre|strong|b|em|i|li|br|p|div|ul|ol|h[1-6]|del|s|strike|a|sup|sub|kbd|mark|ins|abbr|small|var|samp|q|cite|dfn|time|u|span|table|thead|tbody|tfoot|tr|td|th|caption|blockquote|hr|dl|dt|dd|big|tt|figure|figcaption|details|summary)$/i;

var SAFE_HREF = /^(?:https?:\/\/|mailto:|#|\/)/i;

// Parse in an inert template, then rebuild allowed HTML. A browser accepts many
// tag spellings that a regular expression misses. Never insert the parsed tree.
// Disallowed elements remain visible as text because reviews discuss code.
function sanitizeFragment(html) {
  var source = document.createElement('template');
  source.innerHTML = String(html == null ? '' : html);
  var result = document.createElement('div');
  function append(node, parent) {
    if (node.nodeType === 3) {
      parent.appendChild(document.createTextNode(node.textContent));
    } else if (node.nodeType === 1) {
      if (node.namespaceURI !== 'http://www.w3.org/1999/xhtml'
          || !FRAGMENT_TAGS_OK.test(node.localName)) {
        parent.appendChild(document.createTextNode(node.outerHTML));
        return;
      }
      var clean = document.createElement(node.localName);
      if (node.localName === 'a') {
        var href = (node.getAttribute('href') || '').trim();
        if (SAFE_HREF.test(href)) clean.setAttribute('href', href);
      }
      Array.from(node.childNodes).forEach(function(child) { append(child, clean); });
      parent.appendChild(clean);
    }
  }
  Array.from(source.content.childNodes).forEach(function(node) { append(node, result); });
  return result.innerHTML;
}

function renderDiff(diffText, files) {
  var lines = diffText.split('\n');
  var html = '';
  var currentFile = '';
  var fileIndex = 0;
  var inHunk = false;
  var hunkIndex = 0;
  var lineNumOld = 0, lineNumNew = 0;

  for (var i = 0; i < lines.length; i++) {
    var line = lines[i];

    if (line.startsWith('diff --git ')) {
      if (currentFile) html += '</div>';
      currentFile = files[fileIndex++];
      inHunk = false;
      html += '<div class="diff-file"><div class="diff-file-header">'
        + escape(currentFile) + '</div>';
      continue;
    } else if (line.startsWith('@@')) {
      var m = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
      if (m) {
        lineNumOld = parseInt(m[1]);
        lineNumNew = parseInt(m[2]);
        inHunk = true;
        hunkIndex++;
        html += '<div class="diff-hunk-header">'
          + escape(line) + '</div>';
      }
      continue;
    }

    if (!currentFile) continue;
    if (!inHunk || !/^[ +\-]/.test(line)) {
      if (line) html += '<div class="diff-hunk-header">' + escape(line) + '</div>';
      continue;
    }

    var cls, num, side, oldNum = '', newNum = '';
    if (line.startsWith('+')) {
      cls = 'addition'; num = lineNumNew; side = 'RIGHT';
      newNum = lineNumNew;
      lineNumNew++;
    } else if (line.startsWith('-')) {
      cls = 'deletion'; num = lineNumOld; side = 'LEFT';
      oldNum = lineNumOld;
      lineNumOld++;
    } else {
      cls = 'context'; num = lineNumNew; side = 'RIGHT';
      oldNum = lineNumOld;
      newNum = lineNumNew;
      lineNumOld++;
      lineNumNew++;
    }

    html += '<div class="diff-line ' + cls + '"'
      + ' data-file="' + escape(currentFile) + '"'
      + ' data-line="' + num + '"'
      + ' data-old-line="' + oldNum + '"'
      + ' data-new-line="' + newNum + '"'
      + ' data-hunk="' + hunkIndex + '"'
      + ' data-side="' + side + '">'
      + '<span class="line-num">'
      + '<button class="line-add-btn" title="Add review comment">+</button>'
      + num + '</span>'
      + '<span class="line-content">'
      + escape(line || ' ') + '</span></div>';
  }

  return html + (currentFile ? '</div>' : '');
}

function lineOnSide(el, side) {
  return Number(side === 'LEFT' ? el.dataset.oldLine : el.dataset.newLine);
}

function renderCommentMarkers() {
  var lines = document.querySelectorAll('#diffPane .diff-line');
  for (var i = 0; i < lines.length; i++) {
    var el = lines[i];
    var file = el.dataset.file;
    var hasComment = pendingComments.some(function(c) {
      if (c.file !== file) return false;
      var start = c.startLine || c.endLine;
      var num = lineOnSide(el, c.side);
      return num > 0 && num >= start && num <= c.endLine;
    });
    el.classList.toggle('has-comment', hasComment);
  }
}

function renderInlineComments() {
  // Remove any previously rendered inline user comments.
  document.querySelectorAll('#diffPane .inline-user-comment')
    .forEach(function(el) { el.remove(); });

  var stepComments = pendingComments.filter(function(c) {
    return c.stepIndex === current;
  });
  if (stepComments.length === 0) return;

  var allLines = Array.from(
    document.querySelectorAll('#diffPane .diff-line')
  );

  for (var ci = 0; ci < stepComments.length; ci++) {
    var c = stepComments[ci];
    // Find the global index of this comment in pendingComments for delete.
    var globalIdx = pendingComments.indexOf(c);

    // Find the last diff-line matching endLine + file.
    var targetEl = null;
    for (var li = allLines.length - 1; li >= 0; li--) {
      var el = allLines[li];
      if (el.dataset.file === c.file
          && lineOnSide(el, c.side) === c.endLine) {
        targetEl = el;
        break;
      }
    }
    if (!targetEl) continue;

    var lineLabel = c.startLine
      ? c.file + ':' + c.startLine + '-' + c.endLine
      : c.file + ':' + c.endLine;

    var overlay = document.createElement('div');
    overlay.className = 'inline-user-comment';
    overlay.dataset.commentIdx = globalIdx;
    overlay.innerHTML =
      '<div class="inline-user-comment-body">'
      + '<div class="inline-user-comment-label">'
      + escape(lineLabel) + '</div>'
      + '<div class="comment-md">' + markdownToHtml(c.body) + '</div>'
      + '</div>'
      + '<div class="inline-user-comment-actions">'
      + '<button class="btn-edit"'
      + ' onclick="editInlineComment(' + globalIdx + ', this)"'
      + ' title="Edit comment">&#9998;</button>'
      + '<button class="btn-delete"'
      + ' onclick="removeComment(' + globalIdx + ')"'
      + ' title="Remove comment">&times;</button>'
      + '</div>';

    targetEl.insertAdjacentElement('afterend', overlay);
  }
}

function editInlineComment(idx, btn) {
  var overlay = btn.closest('.inline-user-comment');
  var bodyEl = overlay.querySelector('.inline-user-comment-body');
  var actionsEl = overlay.querySelector('.inline-user-comment-actions');
  var currentText = pendingComments[idx].body;

  // Hide the actions buttons and replace body content with a textarea.
  actionsEl.style.display = 'none';
  bodyEl.querySelector('.comment-md').style.display = 'none';

  var textarea = document.createElement('textarea');
  textarea.value = currentText;
  bodyEl.appendChild(textarea);
  textarea.focus();
  textarea.setSelectionRange(currentText.length, currentText.length);

  var editActions = document.createElement('div');
  editActions.className = 'edit-actions';
  editActions.innerHTML =
    '<button class="btn-edit-cancel">Cancel</button>'
    + '<button class="btn-save">Save</button>';
  bodyEl.appendChild(editActions);

  function save() {
    var newText = textarea.value.trim();
    if (newText) {
      pendingComments[idx].body = newText;
    }
    cleanup();
    renderInlineComments();
    renderCommentsTab();
  }

  function cancel() {
    cleanup();
    actionsEl.style.display = '';
    bodyEl.querySelector('.comment-md').style.display = '';
  }

  function cleanup() {
    textarea.remove();
    editActions.remove();
  }

  editActions.querySelector('.btn-save').addEventListener('click', save);
  editActions.querySelector('.btn-edit-cancel').addEventListener('click', cancel);
  textarea.addEventListener('keydown', function(e) {
    if (e.key === 'Escape') cancel();
    if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) save();
  });
}

function render() {
  var step = STEPS[current];

  document.getElementById('stepCounter').textContent =
    (current + 1) + ' / ' + STEPS.length;

  var bar = document.getElementById('progressBar');
  bar.innerHTML = STEPS.map(function(_, i) {
    var cls = i < current ? 'visited'
      : i === current ? 'active' : '';
    return '<div class="progress-segment ' + cls
      + '" onclick="goTo(' + i + ')" title="Step ' + (i+1)
      + ': ' + escape(STEPS[i].message) + '"></div>';
  }).join('');

  closeCommentForm();
  document.getElementById('diffPane').innerHTML = renderDiff(step.diff, step.files);
  document.getElementById('diffPane').scrollTop = 0;
  renderCommentMarkers();
  renderInlineComments();

  document.getElementById('commitTitle').textContent = step.message;
  document.getElementById('stepLabel').textContent =
    'Step ' + (current + 1);
  document.getElementById('fileTags').innerHTML = step.files
    .map(function(f) {
      return '<span class="file-tag">'
        + escape(f.split('/').pop()) + '</span>';
    }).join('');
  document.getElementById('explanation').innerHTML =
    sanitizeFragment(EXPLANATIONS[current]) || '<p>No explanation available.</p>';
  var stepReviewContent = REVIEWS[current];
  if (Array.isArray(stepReviewContent)) {
    if (stepReviewContent.length === 0) {
      document.getElementById('review').innerHTML =
        '<p class="review-no-issues">No review comments for this step.</p>';
    } else {
      document.getElementById('review').innerHTML =
        stepReviewContent.map(function(r, ri) {
          var fileLabel = '';
          if (r.file) {
            fileLabel = '<a class="review-item-file" href="#"'
              + ' onclick="jumpToReview(' + current + ',' + ri + '); return false;">'
              + '<code>'
              + escape(r.file)
              + (r.line ? ':' + r.line : '')
              + (r.end_line ? '-' + r.end_line : '')
              + '</code></a>';
          }
          var convertBtn = '';
          if (PR_META && r.file && r.line) {
            convertBtn = '<button class="btn-convert-comment"'
              + ' data-review-index="' + ri + '"'
              + ' onclick="convertReviewToComment(' + current + ',' + ri + ')"'
              + '>Add as comment</button>';
          }
          var footer = '';
          if (fileLabel || convertBtn) {
            footer = '<div class="review-item-footer">'
              + (fileLabel || '<span></span>') + convertBtn + '</div>';
          }
          return '<div class="review-item severity-'
            + escape(r.severity) + '">'
            + '<div class="review-header">'
            + '<span class="review-severity ' + escape(r.severity)
            + '">' + escape(r.severity).toUpperCase() + '</span>'
            + '</div>'
            + '<div class="review-body"><strong>' + escape(r.title)
            + '</strong> \u2014 ' + sanitizeFragment(r.body) + '</div>'
            + footer
            + '</div>';
        }).join('');
    }
  } else {
    document.getElementById('review').innerHTML =
      sanitizeFragment(stepReviewContent)
      || '<p class="review-no-issues">No review comments for this step.</p>';
  }

  document.getElementById('prevBtn').disabled = current === 0;
  var nextBtn = document.getElementById('nextBtn');
  if (current === STEPS.length - 1) {
    nextBtn.textContent = 'Done';
    nextBtn.disabled = true;
  } else {
    nextBtn.textContent = 'Next \u2192';
    nextBtn.disabled = false;
  }

  updateReviewButtons();
  renderCommentsTab();
}

function switchTab(tab) {
  activeTab = tab;
  document.getElementById('tabExplain')
    .classList.toggle('active', tab === 'explain');
  document.getElementById('tabReview')
    .classList.toggle('active', tab === 'review');
  document.getElementById('contentExplain')
    .classList.toggle('active', tab === 'explain');
  document.getElementById('contentReview')
    .classList.toggle('active', tab === 'review');
}

function switchTopTab(tab) {
  document.getElementById('topTabStep')
    .classList.toggle('active', tab === 'step');
  document.getElementById('topTabComments')
    .classList.toggle('active', tab === 'comments');
  document.getElementById('paneStep')
    .classList.toggle('active', tab === 'step');
  document.getElementById('paneComments')
    .classList.toggle('active', tab === 'comments');
}

function navigate(dir) {
  var next = current + dir;
  if (next >= 0 && next < STEPS.length) {
    current = next;
    render();
  }
}

function goTo(i) { current = i; render(); }

// --- Commenting ---

function closeCommentForm() {
  if (commentFormEl) {
    commentFormEl.remove();
    commentFormEl = null;
  }
  document.querySelectorAll('#diffPane .diff-line.selected')
    .forEach(function(el) { el.classList.remove('selected'); });
  selectionStart = null;
}

function showCommentForm(file, startLine, endLine, side, afterEl) {
  var lineLabel = startLine && startLine !== endLine
    ? 'lines ' + startLine + '-' + endLine
    : 'line ' + endLine;

  var form = document.createElement('div');
  form.className = 'inline-comment-form';
  form.innerHTML =
    '<div class="comment-form-header">'
    + 'Comment on <code>' + escape(file) + '</code> ' + lineLabel
    + '</div>'
    + '<textarea id="commentTextarea" placeholder="Leave a review comment..."></textarea>'
    + '<div class="comment-form-actions">'
    + '<button class="btn-cancel" onclick="closeCommentForm()">Cancel</button>'
    + '<button class="btn-add-comment" id="btnAddComment" onclick="submitComment()" disabled>'
    + 'Add Comment</button>'
    + '</div>';

  afterEl.insertAdjacentElement('afterend', form);
  commentFormEl = form;

  // Store context for submitComment
  form.dataset.file = file;
  form.dataset.startLine = startLine || '';
  form.dataset.endLine = endLine;
  form.dataset.side = side;
  form.dataset.startSide = startLine
    ? (selectionStart ? selectionStart.side : side)
    : '';

  var textarea = form.querySelector('textarea');
  textarea.focus();
  textarea.addEventListener('input', function() {
    form.querySelector('#btnAddComment').disabled =
      !textarea.value.trim();
  });
  textarea.addEventListener('keydown', function(e) {
    if (e.key === 'Escape') {
      closeCommentForm();
    } else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
      if (textarea.value.trim()) submitComment();
    }
  });
}

function submitComment() {
  if (!commentFormEl) return;
  var body = commentFormEl.querySelector('textarea').value.trim();
  if (!body) return;

  var startLine = commentFormEl.dataset.startLine
    ? parseInt(commentFormEl.dataset.startLine) : null;
  var endLine = parseInt(commentFormEl.dataset.endLine);
  var side = commentFormEl.dataset.side;
  var startSide = commentFormEl.dataset.startSide || side;

  pendingComments.push({
    file: commentFormEl.dataset.file,
    startLine: startLine && startLine !== endLine ? startLine : null,
    endLine: endLine,
    side: side,
    startSide: startSide,
    body: body,
    stepIndex: current
  });

  closeCommentForm();
  renderCommentMarkers();
  renderInlineComments();
  renderCommentsTab();
  showToast('Comment added');
}

function removeComment(idx) {
  pendingComments.splice(idx, 1);
  updateReviewButtons();
  renderCommentMarkers();
  renderInlineComments();
  renderCommentsTab();
}

// Inline tags with no Markdown spelling. GitHub-flavoured Markdown renders
// inline HTML, so these survive the trip verbatim instead of being discarded --
// dropping them used to turn 2<sup>64</sup> into 264, a different number posted
// under a comment that looked correct on the page.
var INLINE_HTML_OK =
  /^(sup|sub|kbd|mark|ins|abbr|small|var|samp|q|cite|dfn|time|u)$/i;

function escapeMarkdown(text) {
  return text.replace(/[\\`*_~\[\]<>#|+.!()=&-]/g, '\\$&');
}

function codeFence(text, minimum) {
  var runs = text.match(/`+/g) || [];
  return '`'.repeat(runs.reduce(function(longest, run) {
    return Math.max(longest, run.length + 1);
  }, minimum));
}

// Walk the sanitized DOM so code text is never processed as formatting or tags.
function htmlToMarkdown(html) {
  var source = document.createElement('template');
  source.innerHTML = sanitizeFragment(html);
  function convert(node) {
    if (node.nodeType === 3) return escapeMarkdown(node.textContent.replace(/\s+/g, ' '));
    if (node.nodeType !== 1) return '';
    var tag = node.localName;
    var text = node.textContent;
    if (tag === 'pre') {
      var fence = codeFence(text, 3);
      return '\n\n' + fence + '\n' + text + (text.endsWith('\n') ? '' : '\n') + fence + '\n\n';
    }
    if (tag === 'code') {
      var ticks = codeFence(text, 1);
      var padding = /^[ `]|[ `]$/.test(text) ? ' ' : '';
      return ticks + padding + text + padding + ticks;
    }
    if (tag === 'tr') {
      return Array.from(node.children).map(function(cell) {
        return convert(cell).trim();
      }).join(' | ') + '  \n';
    }
    if (/^(table|thead|tbody|tfoot)$/.test(tag)) {
      var rows = Array.from(node.children).map(convert).join('');
      return tag === 'table' ? '\n\n' + rows.trim() + '\n\n' : rows;
    }
    var inner = Array.from(node.childNodes).map(convert).join('');
    if (tag === 'strong' || tag === 'b') return '**' + inner + '**';
    if (tag === 'em' || tag === 'i') return '_' + inner + '_';
    if (/^(del|s|strike)$/.test(tag)) return '~~' + inner + '~~';
    if (tag === 'a' && node.hasAttribute('href')) {
      var href = node.getAttribute('href').replace(/[\\()\s]/g, function(character) {
        if (character === '(') return '%28';
        if (character === ')') return '%29';
        return encodeURIComponent(character);
      });
      return '[' + inner + '](' + href + ')';
    }
    if (INLINE_HTML_OK.test(tag)) return '<' + tag + '>' + inner + '</' + tag + '>';
    if (tag === 'br') return '  \n';
    if (tag === 'li') return '- ' + inner.trim() + '\n';
    if (tag === 'hr') return '\n\n---\n\n';
    if (tag === 'blockquote') {
      return '\n\n' + inner.trim().split('\n').map(function(line) {
        return '> ' + line;
      }).join('\n') + '\n\n';
    }
    if (/^(p|div|ul|ol|h[1-6]|caption|dl|dt|dd)$/.test(tag)) return inner.trim() + '\n\n';
    return inner;
  }
  return Array.from(source.content.childNodes).map(convert).join('').trim();
}

// Render formatting only outside code spans. Escaped Markdown punctuation stays
// literal, including characters quoted from a finding's HTML body.
function markdownInline(text) {
  var token = /\\([\\`*_~\[\]<>#|+.!()=&-])|(?<!`)(`+)(?!`)([\s\S]*?)(?<!`)\2(?!`)/g;
  var protectedParts = [];
  var marker = '\u0000';
  while (text.includes(marker)) marker += '\u0000';
  function format(part) {
    return escape(part)
      .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
      .replace(/~~([^~]+)~~/g, '<del>$1</del>')
      .replace(/(^|[\s(])_([^_\n]+)_(?=[\s.,;:!?)]|$)/g, '$1<em>$2</em>')
      .replace(/&lt;(\/?)(sup|sub|kbd|mark|ins|abbr|small|var|samp|q|cite|dfn|time|u)&gt;/gi, '<$1$2>')
      .replace(/\n/g, '<br>');
  }
  var marked = text.replace(token, function(_match, literal, _ticks, code) {
    var fragment;
    if (literal) {
      fragment = escape(literal);
    } else {
      code = code.replace(/\n/g, ' ');
      if (/^ .* $/.test(code) && /[^ ]/.test(code)) code = code.slice(1, -1);
      fragment = '<code>' + escape(code) + '</code>';
    }
    protectedParts.push(fragment);
    return marker + (protectedParts.length - 1) + marker;
  });
  // Restore literal punctuation and code after formatting their surrounding text.
  return format(marked).split(marker).map(function(part, index) {
    return index % 2 ? protectedParts[Number(part)] : part;
  }).join('');
}

// Fences are handled before paragraph and inline formatting, so blank lines,
// indentation, Markdown markers, and literal HTML inside code remain unchanged.
function markdownToHtml(md) {
  var lines = String(md == null ? '' : md).replace(/\r\n?/g, '\n').split('\n');
  var html = '';
  var paragraph = [];
  function flush() {
    if (paragraph.length) html += '<p>' + markdownInline(paragraph.join('\n')) + '</p>';
    paragraph = [];
  }
  for (var i = 0; i < lines.length; i++) {
    if (/^ {0,3}>/.test(lines[i])) {
      flush();
      var quote = [];
      do {
        quote.push(lines[i].replace(/^ {0,3}> ?/, ''));
        i++;
      } while (i < lines.length && /^ {0,3}>/.test(lines[i]));
      i--;
      html += '<blockquote>' + markdownToHtml(quote.join('\n')) + '</blockquote>';
      continue;
    }
    var fence = /^ {0,3}(`{3,}|~{3,})[^`]*$/.exec(lines[i]);
    if (fence) {
      flush();
      var code = [];
      var closing = new RegExp('^ {0,3}' + fence[1][0] + '{' + fence[1].length + ',}\\s*$');
      while (++i < lines.length && !closing.test(lines[i])) code.push(lines[i]);
      html += '<pre><code>' + escape(code.join('\n')) + '</code></pre>';
    } else if (!lines[i].trim()) {
      flush();
    } else {
      paragraph.push(lines[i]);
    }
  }
  flush();
  return html;
}

function hasReviewComment(stepIdx, reviewIdx) {
  return pendingComments.some(function(comment) {
    return comment.stepIndex === stepIdx && comment.reviewIndex === reviewIdx;
  });
}

function updateReviewButtons() {
  document.querySelectorAll('#review .btn-convert-comment').forEach(function(button) {
    var added = hasReviewComment(current, Number(button.dataset.reviewIndex));
    button.disabled = added;
    button.textContent = added ? 'Added' : 'Add as comment';
  });
}

function convertReviewToComment(stepIdx, reviewIdx) {
  var r = REVIEWS[stepIdx][reviewIdx];
  if (!r || !r.file || !r.line || hasReviewComment(stepIdx, reviewIdx)) return;

  var endLine = r.end_line || r.line;
  var body = '**' + escapeMarkdown(r.title) + '** (' + r.severity.toUpperCase() + ')\n\n'
    + htmlToMarkdown(r.body);

  pendingComments.push({
    file: r.file,
    startLine: r.end_line && r.line !== r.end_line ? r.line : null,
    endLine: endLine,
    side: r.side,
    startSide: r.side,
    body: body,
    stepIndex: stepIdx,
    reviewIndex: reviewIdx
  });

  renderCommentMarkers();
  renderInlineComments();
  renderCommentsTab();

  updateReviewButtons();

  showToast('Review converted to comment');

  // Scroll the diff to the newly added inline comment.
  var newIdx = pendingComments.length - 1;
  var overlay = document.querySelector(
    '#diffPane .inline-user-comment[data-comment-idx="' + newIdx + '"]'
  );
  if (overlay) {
    overlay.scrollIntoView({ behavior: 'smooth', block: 'center' });
    overlay.style.outline = '2px solid var(--accent)';
    setTimeout(function() { overlay.style.outline = ''; }, 1500);
  }
}

function jumpToReview(stepIdx, reviewIdx) {
  var review = REVIEWS[stepIdx][reviewIdx];
  jumpToLine(review.file, review.line, review.end_line, review.side);
}

function jumpToLine(file, line, endLine, side) {
  if (!endLine || endLine < line) endLine = line;
  var allLines = document.querySelectorAll('#diffPane .diff-line');
  var matched = [];
  for (var i = 0; i < allLines.length; i++) {
    var el = allLines[i];
    var num = lineOnSide(el, side);
    if (el.dataset.file === file && num > 0 && num >= line && num <= endLine) {
      matched.push(el);
    }
  }
  if (matched.length === 0) return;
  matched[0].scrollIntoView({ behavior: 'smooth', block: 'center' });

  // Remove any previous highlight overlay.
  var prev = document.getElementById('jumpHighlight');
  if (prev) prev.remove();

  var pane = document.getElementById('diffPane');
  var paneRect = pane.getBoundingClientRect();
  var scrollTop = pane.scrollTop;
  var firstRect = matched[0].getBoundingClientRect();
  var lastRect = matched[matched.length - 1].getBoundingClientRect();

  var overlay = document.createElement('div');
  overlay.id = 'jumpHighlight';
  overlay.style.cssText =
    'position:absolute;'
    + 'top:' + (firstRect.top - paneRect.top + scrollTop) + 'px;'
    + 'left:0;'
    + 'width:' + pane.scrollWidth + 'px;'
    + 'height:' + (lastRect.bottom - firstRect.top) + 'px;'
    + 'border:2px solid var(--accent);'
    + 'border-radius:4px;'
    + 'pointer-events:none;'
    + 'z-index:3;'
    + 'transition:opacity 0.3s;';
  pane.style.position = 'relative';
  pane.appendChild(overlay);

  setTimeout(function() {
    overlay.style.opacity = '0';
    setTimeout(function() { overlay.remove(); }, 300);
  }, 1500);
}

function jumpToComment(idx) {
  var c = pendingComments[idx];
  if (!c) return;

  // Switch to the Step tab so the diff is visible.
  switchTopTab('step');

  // Navigate to the step where the comment was made.
  if (c.stepIndex !== current) {
    current = c.stepIndex;
    render();
  }

  // Find the inline comment overlay and scroll to it.
  var overlay = document.querySelector(
    '#diffPane .inline-user-comment[data-comment-idx="' + idx + '"]'
  );
  if (overlay) {
    overlay.scrollIntoView({ behavior: 'smooth', block: 'center' });
    // Flash highlight.
    overlay.style.outline = '2px solid var(--accent)';
    setTimeout(function() { overlay.style.outline = ''; }, 1500);
  }
}

function renderCommentsTab() {
  if (!PR_META) return;

  var list = document.getElementById('commentsList');
  var badge = document.getElementById('commentBadge');

  badge.textContent = pendingComments.length;
  badge.style.display = pendingComments.length > 0 ? 'inline-block' : 'none';
  updateCommandBox();

  if (pendingComments.length === 0) {
    list.innerHTML = '<p class="comments-empty">'
      + 'No comments yet. Click the <strong>+</strong> icon '
      + 'on any diff line to add a review comment.</p>';
    return;
  }

  var html = '';
  for (var i = 0; i < pendingComments.length; i++) {
    var c = pendingComments[i];
    var lineLabel = c.startLine
      ? 'L' + c.startLine + '-L' + c.endLine
      : 'L' + c.endLine;
    html += '<div class="pending-comment-item">'
      + '<button class="pending-comment-delete" onclick="removeComment('
      + i + ')" title="Remove comment">&times;</button>'
      + '<a class="pending-comment-file" href="#" onclick="jumpToComment('
      + i + '); return false;">'
      + escape(c.file) + ':' + lineLabel + '</a>'
      + '<div class="pending-comment-body comment-md">'
      + markdownToHtml(c.body) + '</div></div>';
  }

  list.innerHTML = html;
}

function reviewSummaryMissing() {
  return document.getElementById('reviewEvent').value !== 'APPROVE'
    && !document.getElementById('reviewBody').value.trim();
}

function buildReviewPayload() {
  if (reviewSummaryMissing()) throw new Error('Add a review summary for Comment or Request Changes.');
  var event = document.getElementById('reviewEvent').value;
  var body = document.getElementById('reviewBody').value.trim();
  var comments = pendingComments.map(function(c) {
    var obj = {
      path: c.file,
      line: c.endLine,
      side: c.side,
      body: c.body
    };
    if (c.startLine) {
      obj.start_line = c.startLine;
      obj.start_side = c.startSide;
    }
    return obj;
  });

  var payload = {
    commit_id: PR_META.head_sha,
    event: event,
    comments: comments
  };
  if (body) payload.body = body;
  return payload;
}

function buildGhCommand() {
  var payload = buildReviewPayload();
  var json = JSON.stringify(payload, null, 2);
  var endpoint = 'repos/' + PR_META.owner + '/' + PR_META.repo
    + '/pulls/' + PR_META.pr_number + '/reviews';
  // printf preserves JSON backslashes in bash, zsh, and POSIX shells.
  return "printf '%s\\n' " + shellQuote(json) + ' | gh api ' + shellQuote(endpoint)
    + ' --method POST --input -';
}

function shellQuote(value) {
  return "'" + value.replace(/'/g, "'\\''") + "'";
}

function updateCommandBox() {
  var box = document.getElementById('ghCommandBox');
  var needsSummary = document.getElementById('reviewEvent').value !== 'APPROVE';
  document.getElementById('reviewBody').required = needsSummary;
  document.getElementById('reviewBodyLabel').textContent =
    'Review summary (' + (needsSummary ? 'required' : 'optional') + ')';
  var ready = pendingComments.length > 0 && !reviewSummaryMissing();
  document.getElementById('btnCopyCommand').disabled = !ready;
  if (ready) {
    box.textContent = buildGhCommand();
    box.classList.add('visible');
  } else {
    box.textContent = '';
    box.classList.remove('visible');
  }
}

function copyReviewCommand() {
  if (pendingComments.length === 0 || reviewSummaryMissing()) return;
  var cmd = buildGhCommand();
  function fallback() {
    var box = document.getElementById('ghCommandBox');
    var range = document.createRange();
    range.selectNodeContents(box);
    var sel = window.getSelection();
    sel.removeAllRanges();
    sel.addRange(range);
    showToast('Could not copy. Select and copy the command manually.');
  }
  if (!navigator.clipboard) {
    fallback();
    return;
  }
  navigator.clipboard.writeText(cmd).then(function() {
    showToast('Copied to clipboard — paste in your terminal');
  }, fallback);
}

function showToast(msg) {
  var el = document.getElementById('toast');
  el.textContent = msg;
  el.classList.add('show');
  clearTimeout(showToast._timer);
  showToast._timer = setTimeout(function() {
    el.classList.remove('show');
  }, 2500);
}

// --- Diff drag selection ---

var dragState = null; // {file, anchorEl, anchorIdx, allLines}

function getDiffLines() {
  return Array.from(document.querySelectorAll('#diffPane .diff-line'));
}

function selectRange(allLines, fromIdx, toIdx) {
  var lo = Math.min(fromIdx, toIdx);
  var hi = Math.max(fromIdx, toIdx);
  allLines.forEach(function(el, i) {
    el.classList.toggle('selected', i >= lo && i <= hi);
  });
}

var diffPane = document.getElementById('diffPane');

diffPane.addEventListener('mousedown', function(e) {
  // Start drag from + button or from line-num area
  var btn = e.target.closest('.line-add-btn');
  if (!btn || !PR_META) return;

  e.preventDefault(); // prevent text selection

  var lineEl = btn.closest('.diff-line');
  var file = lineEl.dataset.file;

  closeCommentForm();

  var allLines = getDiffLines();
  var idx = allLines.indexOf(lineEl);
  if (idx === -1) return;

  lineEl.classList.add('selected');
  dragState = { file: file, anchorEl: lineEl, anchorIdx: idx, allLines: allLines };
  diffPane.classList.add('dragging');
});

document.addEventListener('mousemove', function(e) {
  if (!dragState) return;

  // Find the diff-line under the cursor
  var el = document.elementFromPoint(e.clientX, e.clientY);
  if (!el) return;
  var lineEl = el.closest('.diff-line');
  if (!lineEl || lineEl.dataset.file !== dragState.file) return;
  // A GitHub range must have both endpoints on the same side in one hunk.
  if (lineEl.dataset.hunk !== dragState.anchorEl.dataset.hunk
      || !lineOnSide(lineEl, dragState.anchorEl.dataset.side)) return;

  var idx = dragState.allLines.indexOf(lineEl);
  if (idx === -1) return;
  var lo = Math.min(idx, dragState.anchorIdx);
  var hi = Math.max(idx, dragState.anchorIdx);
  if (dragState.allLines.slice(lo, hi + 1).some(function(row) {
    return !lineOnSide(row, dragState.anchorEl.dataset.side);
  })) return;

  selectRange(dragState.allLines, dragState.anchorIdx, idx);
});

document.addEventListener('mouseup', function(e) {
  if (!dragState) return;

  diffPane.classList.remove('dragging');

  var allLines = dragState.allLines;
  var anchorSide = dragState.anchorEl.dataset.side;
  var file = dragState.file;
  dragState = null;

  // Find the selected range
  var selected = allLines.filter(function(el) {
    return el.classList.contains('selected');
  });
  if (selected.length === 0) return;

  var firstEl = selected[0];
  var lastEl = selected[selected.length - 1];

  // All selected must be same file (clamp at file boundary)
  if (firstEl.dataset.file !== file || lastEl.dataset.file !== file) {
    // Re-select only lines in the anchor's file
    selected = selected.filter(function(el) {
      return el.dataset.file === file;
    });
    if (selected.length === 0) return;
    // Clear out-of-file selections
    allLines.forEach(function(el) {
      if (el.dataset.file !== file) el.classList.remove('selected');
    });
    firstEl = selected[0];
    lastEl = selected[selected.length - 1];
  }

  var startLine = lineOnSide(firstEl, anchorSide);
  var endLine = lineOnSide(lastEl, anchorSide);
  var side = anchorSide;

  selectionStart = {
    el: firstEl,
    file: file,
    line: startLine,
    side: side
  };

  showCommentForm(
    file,
    selected.length > 1 ? startLine : null,
    endLine,
    side,
    lastEl
  );
});

// Update command preview when review options change
document.getElementById('reviewEvent').addEventListener('change', updateCommandBox);
document.getElementById('reviewBody').addEventListener('input', updateCommandBox);

// --- Keyboard navigation ---

document.addEventListener('keydown', function(e) {
  // Leave keyboard input to the focused form control.
  if (/^(TEXTAREA|INPUT|SELECT)$/.test(e.target.tagName) || e.target.isContentEditable)
    return;
  if (e.key === 'ArrowRight' || e.key === 'j') navigate(1);
  if (e.key === 'ArrowLeft' || e.key === 'k') navigate(-1);
});

// --- Init ---

if (PR_META) {
  document.getElementById('diffPane').classList.add('commenting-enabled');
  document.getElementById('topTabComments').style.display = '';
}

render();
</script>
</body>
</html>

Requirements and compatibility

Written for: Claude Code

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Needs: Claude Code frontmatter, Needs Python

Security

What our scanner noticed· Low risk

  • Scripts that your agent may run

This skill includes scripts that your agent may run. Read them before you use it.

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Published by
Trail of Bits
Publisher
Trail of Bits (Official)
Source
trailofbits/skills · plugins/review-walkthrough/skills/review-walkthrough · commit e4dd0bb
Licence
Creative Commons Attribution-ShareAlike 4.0 International (redistribution allowed)
  • LICENSE (added by AutoGineer from LICENSE)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by Trail of Bits.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download review-walkthrough.zip