Guest User

OpenCode Session json -> html converter

a guest
Oct 25th, 2026
44
0
Never
1
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 87.02 KB | Software | 0 0
  1. #!/usr/bin/env python3
  2. """Render an OpenCode session JSON log as a single self-contained HTML file.
  3.  
  4. Usage:
  5.    python3 render_session.py opencode-sesion-log.json
  6.    python3 render_session.py opencode-sesion-log.json -o session.html --open
  7.    python3 render_session.py - < session.json > session.html
  8.  
  9. The output has no external dependencies: CSS and JavaScript are embedded, and
  10. all session text is escaped before it is written into the page.
  11. """
  12.  
  13. from __future__ import annotations
  14.  
  15. import argparse
  16. import html
  17. import json
  18. import os
  19. import re
  20. import sys
  21. import webbrowser
  22. from collections import Counter
  23. from datetime import datetime
  24. from pathlib import Path
  25. from urllib.parse import urlsplit
  26.  
  27.  
  28. # ---------------------------------------------------------------------------
  29. # Small helpers
  30. # ---------------------------------------------------------------------------
  31.  
  32. def esc(value) -> str:
  33.     """Escape text for use in HTML text nodes and attributes."""
  34.     return html.escape("" if value is None else str(value), quote=True)
  35.  
  36.  
  37. def attr(value) -> str:
  38.     """Escape an attribute value and flatten newlines."""
  39.     return esc(value).replace("\r", " ").replace("\n", " ")
  40.  
  41.  
  42. def truncate(value, limit: int = 140) -> str:
  43.     text = "" if value is None else str(value)
  44.     if len(text) <= limit:
  45.         return text
  46.     return text[: max(0, limit - 1)].rstrip() + "…"
  47.  
  48.  
  49. def first_line(value, limit: int = 140) -> str:
  50.     text = "" if value is None else str(value).strip()
  51.     if not text:
  52.         return ""
  53.     return truncate(text.splitlines()[0], limit)
  54.  
  55.  
  56. def fmt_int(value) -> str:
  57.     try:
  58.         return f"{int(value):,}"
  59.     except (TypeError, ValueError):
  60.         return "—"
  61.  
  62.  
  63. def short_num(value) -> str:
  64.     try:
  65.         n = int(value)
  66.     except (TypeError, ValueError):
  67.         return "—"
  68.     if abs(n) >= 1_000_000:
  69.         return f"{n / 1_000_000:.1f}M".replace(".0M", "M")
  70.     if abs(n) >= 1_000:
  71.         return f"{n / 1_000:.1f}k".replace(".0k", "k")
  72.     return str(n)
  73.  
  74.  
  75. def compact_num(value) -> str:
  76.     """Shorter number for tight stat cards."""
  77.     try:
  78.         n = int(value)
  79.     except (TypeError, ValueError):
  80.         return "—"
  81.     if abs(n) >= 1_000_000:
  82.         return f"{n / 1_000_000:.1f}M".replace(".0M", "M")
  83.     if abs(n) >= 10_000:
  84.         return f"{n / 1_000:.0f}k"
  85.     if abs(n) >= 1_000:
  86.         return f"{n / 1_000:.1f}k".replace(".0k", "k")
  87.     return str(n)
  88.  
  89.  
  90. def fmt_bytes(value) -> str:
  91.     try:
  92.         n = int(value)
  93.     except (TypeError, ValueError):
  94.         return "—"
  95.     if n < 1024:
  96.         return f"{n} B"
  97.     if n < 1024 * 1024:
  98.         return f"{n / 1024:.1f} KB"
  99.     return f"{n / (1024 * 1024):.1f} MB"
  100.  
  101.  
  102. def fmt_duration(ms) -> str:
  103.     if ms is None:
  104.         return ""
  105.     try:
  106.         ms = int(ms)
  107.     except (TypeError, ValueError):
  108.         return ""
  109.     if ms < 1000:
  110.         return f"{ms} ms"
  111.     seconds = ms / 1000.0
  112.     if seconds < 10:
  113.         return f"{seconds:.1f} s"
  114.     if seconds < 60:
  115.         return f"{seconds:.0f} s"
  116.     minutes = int(seconds // 60)
  117.     seconds = int(seconds % 60)
  118.     if minutes < 60:
  119.         return f"{minutes}m {seconds:02d}s"
  120.     hours = minutes // 60
  121.     minutes = minutes % 60
  122.     return f"{hours}h {minutes:02d}m"
  123.  
  124.  
  125. def fmt_elapsed(ms, start_ms) -> str:
  126.     if not ms or not start_ms:
  127.         return ""
  128.     try:
  129.         delta = max(0, int(ms) - int(start_ms))
  130.     except (TypeError, ValueError):
  131.         return ""
  132.     seconds = delta // 1000
  133.     hours, rem = divmod(seconds, 3600)
  134.     minutes, seconds = divmod(rem, 60)
  135.     if hours:
  136.         return f"+{hours}:{minutes:02d}:{seconds:02d}"
  137.     return f"+{minutes:02d}:{seconds:02d}"
  138.  
  139.  
  140. def _dt(ms):
  141.     if not ms:
  142.         return None
  143.     try:
  144.         return datetime.fromtimestamp(int(ms) / 1000).astimezone()
  145.     except (TypeError, ValueError, OSError):
  146.         return None
  147.  
  148.  
  149. def fmt_date(ms, short: bool = False) -> str:
  150.     dt = _dt(ms)
  151.     if dt is None:
  152.         return "—"
  153.     if short:
  154.         return dt.strftime("%d %b, %H:%M")
  155.     return dt.strftime("%d %b %Y, %H:%M:%S %Z")
  156.  
  157.  
  158. def fmt_datetime_attr(ms) -> str:
  159.     dt = _dt(ms)
  160.     if dt is None:
  161.         return ""
  162.     return dt.isoformat(timespec="seconds")
  163.  
  164.  
  165. def fmt_cost(value) -> str:
  166.     try:
  167.         cost = float(value or 0)
  168.     except (TypeError, ValueError):
  169.         return "—"
  170.     if cost == 0:
  171.         return "$0.00"
  172.     if cost < 0.01:
  173.         return f"${cost:.4f}"
  174.     return f"${cost:.2f}"
  175.  
  176.  
  177. def short_path(path, cwd=None) -> str:
  178.     if not path:
  179.         return ""
  180.     text = str(path)
  181.     if cwd:
  182.         try:
  183.             rel = os.path.relpath(text, cwd)
  184.             if not rel.startswith(".."):
  185.                 return rel
  186.         except (ValueError, OSError):
  187.             pass
  188.     home = os.path.expanduser("~")
  189.     if text.startswith(home + os.sep):
  190.         return "~" + text[len(home):]
  191.     return text
  192.  
  193.  
  194. def strip_ansi(text) -> str:
  195.     if not text:
  196.         return ""
  197.     return re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", str(text))
  198.  
  199.  
  200. # ---------------------------------------------------------------------------
  201. # A small, dependency-free Markdown renderer
  202. # ---------------------------------------------------------------------------
  203.  
  204. LIST_RE = re.compile(r"^(\s*)([-*+]|\d+[.)])\s+(.*)$")
  205. FENCE_RE = re.compile(r"^\s*(```|~~~)(.*)$")
  206. HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$")
  207. HR_RE = re.compile(r"^\s{0,3}([-*_])(?:\s*\1){2,}\s*$")
  208. QUOTE_RE = re.compile(r"^\s{0,3}>")
  209. TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$")
  210.  
  211.  
  212. def _safe_url(url):
  213.     text = (url or "").strip()
  214.     if not text:
  215.         return None
  216.     try:
  217.         parts = urlsplit(text)
  218.     except ValueError:
  219.         return None
  220.     if parts.scheme and parts.scheme.lower() not in ("http", "https", "mailto"):
  221.         return None
  222.     return text
  223.  
  224.  
  225. def _emphasis(text: str) -> str:
  226.     text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text, flags=re.S)
  227.     text = re.sub(r"__(.+?)__", r"<strong>\1</strong>", text, flags=re.S)
  228.     text = re.sub(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", r"<em>\1</em>", text, flags=re.S)
  229.     text = re.sub(r"(?<!_)_(?!_)(.+?)(?<!_)_(?!_)", r"<em>\1</em>", text, flags=re.S)
  230.     text = re.sub(r"~~(.+?)~~", r"<del>\1</del>", text, flags=re.S)
  231.     return text
  232.  
  233.  
  234. def _inline(raw) -> str:
  235.     """Render inline Markdown after escaping all user text first."""
  236.     text = esc(str(raw).replace("\x00", ""))
  237.     tokens = []
  238.  
  239.     def stash(fragment: str) -> str:
  240.         tokens.append(fragment)
  241.         return f"\x00{len(tokens) - 1}\x00"
  242.  
  243.     def resolve(value: str) -> str:
  244.         for index, fragment in enumerate(tokens):
  245.             value = value.replace(f"\x00{index}\x00", fragment)
  246.         return value
  247.  
  248.     # Inline code first, so links/emphasis inside code are not interpreted.
  249.     text = re.sub(r"`([^`\n]+)`", lambda m: stash("<code>" + m.group(1) + "</code>"), text)
  250.  
  251.     def link_repl(match):
  252.         label, url = match.group(1), match.group(2)
  253.         safe = _safe_url(html.unescape(url))
  254.         if not safe:
  255.             return match.group(0)
  256.         label_html = _emphasis(resolve(label))
  257.         return stash(
  258.             f'<a href="{esc(safe)}" target="_blank" rel="noopener noreferrer">{label_html}</a>'
  259.         )
  260.  
  261.     text = re.sub(r"\[([^\]]+)\]\(([^)\s]+)\)", link_repl, text)
  262.  
  263.     def bare_url_repl(match):
  264.         raw = match.group(0)
  265.         url = raw
  266.         trailing = ""
  267.         while url and url[-1] in ".,;:!?)]}":
  268.             trailing = url[-1] + trailing
  269.             url = url[:-1]
  270.         if not url:
  271.             return raw
  272.         return (
  273.             stash(
  274.                 f'<a href="{esc(html.unescape(url))}" target="_blank" rel="noopener noreferrer">{url}</a>'
  275.             )
  276.             + trailing
  277.         )
  278.  
  279.     text = re.sub(r"https?://[^\s<>\x00]+", bare_url_repl, text)
  280.     text = _emphasis(text)
  281.     return resolve(text)
  282.  
  283.  
  284. def _is_table_start(lines, index: int) -> bool:
  285.     if index + 1 >= len(lines):
  286.         return False
  287.     return "|" in lines[index] and bool(TABLE_SEP_RE.match(lines[index + 1]))
  288.  
  289.  
  290. def _is_block_start(lines, index: int) -> bool:
  291.     line = lines[index]
  292.     if FENCE_RE.match(line):
  293.         return True
  294.     if HEADING_RE.match(line):
  295.         return True
  296.     if HR_RE.match(line):
  297.         return True
  298.     if QUOTE_RE.match(line):
  299.         return True
  300.     if LIST_RE.match(line):
  301.         return True
  302.     if _is_table_start(lines, index):
  303.         return True
  304.     return False
  305.  
  306.  
  307. def _split_table_row(line: str):
  308.     text = line.strip()
  309.     if text.startswith("|"):
  310.         text = text[1:]
  311.     if text.endswith("|"):
  312.         text = text[:-1]
  313.     return [cell.strip() for cell in text.split("|")]
  314.  
  315.  
  316. def _table_alignments(separator: str):
  317.     result = []
  318.     for cell in _split_table_row(separator):
  319.         cell = cell.strip()
  320.         if cell.startswith(":") and cell.endswith(":"):
  321.             result.append("center")
  322.         elif cell.endswith(":"):
  323.             result.append("right")
  324.         else:
  325.             result.append("")
  326.     return result
  327.  
  328.  
  329. def _render_table(header, alignments, rows) -> str:
  330.     def cell(tag, text, index):
  331.         align = alignments[index] if index < len(alignments) else ""
  332.         style = f' style="text-align:{align}"' if align else ""
  333.         return f"<{tag}{style}>{_inline(text)}</{tag}>"
  334.  
  335.     head = "".join(cell("th", value, index) for index, value in enumerate(header))
  336.     body = []
  337.     for row in rows:
  338.         row = list(row) + [""] * max(0, len(header) - len(row))
  339.         body.append(
  340.             "<tr>" + "".join(cell("td", value, index) for index, value in enumerate(row[: len(header)])) + "</tr>"
  341.         )
  342.     return (
  343.         '<div class="table-wrap"><table><thead><tr>'
  344.         + head
  345.         + "</tr></thead><tbody>"
  346.         + "".join(body)
  347.         + "</tbody></table></div>"
  348.     )
  349.  
  350.  
  351. def render_markdown(text) -> str:
  352.     """Convert a practical subset of Markdown to safe HTML."""
  353.     text = str(text or "").replace("\r\n", "\n").replace("\r", "\n").replace("\x00", "")
  354.     lines = text.split("\n")
  355.     out = []
  356.     index = 0
  357.     count = len(lines)
  358.  
  359.     while index < count:
  360.         line = lines[index]
  361.         if not line.strip():
  362.             index += 1
  363.             continue
  364.  
  365.         fence = FENCE_RE.match(line)
  366.         if fence:
  367.             marker = fence.group(1)
  368.             language = re.sub(r"[^\w+-]", "", fence.group(2).strip())
  369.             index += 1
  370.             body = []
  371.             while index < count and not re.match(r"^\s*" + re.escape(marker) + r"\s*$", lines[index]):
  372.                 body.append(lines[index])
  373.                 index += 1
  374.             index += 1
  375.             class_attr = f' class="language-{language}"' if language else ""
  376.             out.append(f'<pre class="md-code"><code{class_attr}>{esc(chr(10).join(body))}</code></pre>')
  377.             continue
  378.  
  379.         heading = HEADING_RE.match(line)
  380.         if heading:
  381.             level = len(heading.group(1))
  382.             out.append(f"<h{level}>{_inline(heading.group(2))}</h{level}>")
  383.             index += 1
  384.             continue
  385.  
  386.         if HR_RE.match(line):
  387.             out.append("<hr>")
  388.             index += 1
  389.             continue
  390.  
  391.         if QUOTE_RE.match(line):
  392.             body = []
  393.             while index < count and QUOTE_RE.match(lines[index]):
  394.                 body.append(re.sub(r"^\s{0,3}>\s?", "", lines[index]))
  395.                 index += 1
  396.             out.append("<blockquote>" + render_markdown("\n".join(body)) + "</blockquote>")
  397.             continue
  398.  
  399.         if _is_table_start(lines, index):
  400.             header = _split_table_row(line)
  401.             alignments = _table_alignments(lines[index + 1])
  402.             index += 2
  403.             rows = []
  404.             while index < count and lines[index].strip() and "|" in lines[index]:
  405.                 rows.append(_split_table_row(lines[index]))
  406.                 index += 1
  407.             out.append(_render_table(header, alignments, rows))
  408.             continue
  409.  
  410.         list_match = LIST_RE.match(line)
  411.         if list_match:
  412.             ordered = bool(re.match(r"\d", list_match.group(2)))
  413.             base_indent = len(list_match.group(1))
  414.             items = []
  415.             while index < count:
  416.                 item_match = LIST_RE.match(lines[index])
  417.                 if not item_match:
  418.                     break
  419.                 if len(item_match.group(1)) != base_indent:
  420.                     break
  421.                 if bool(re.match(r"\d", item_match.group(2))) != ordered:
  422.                     break
  423.                 item_lines = [item_match.group(3)]
  424.                 index += 1
  425.                 while index < count:
  426.                     nxt = lines[index]
  427.                     if not nxt.strip():
  428.                         if (
  429.                             index + 1 < count
  430.                             and lines[index + 1].strip()
  431.                             and len(lines[index + 1]) - len(lines[index + 1].lstrip()) > base_indent
  432.                             and not LIST_RE.match(lines[index + 1])
  433.                         ):
  434.                             item_lines.append("")
  435.                             index += 1
  436.                             continue
  437.                         break
  438.                     if LIST_RE.match(nxt):
  439.                         break
  440.                     if len(nxt) - len(nxt.lstrip()) > base_indent:
  441.                         item_lines.append(nxt.strip())
  442.                         index += 1
  443.                         continue
  444.                     break
  445.                 items.append("\n".join(item_lines))
  446.  
  447.             tag = "ol" if ordered else "ul"
  448.             rendered_items = []
  449.             for item in items:
  450.                 if "\n" in item:
  451.                     rendered_items.append(f"<li>{render_markdown(item)}</li>")
  452.                 else:
  453.                     rendered_items.append(f"<li>{_inline(item)}</li>")
  454.             out.append(f"<{tag}>{''.join(rendered_items)}</{tag}>")
  455.             continue
  456.  
  457.         paragraph = [line]
  458.         index += 1
  459.         while index < count and lines[index].strip() and not _is_block_start(lines, index):
  460.             paragraph.append(lines[index])
  461.             index += 1
  462.         out.append("<p>" + "<br>".join(_inline(part) for part in paragraph) + "</p>")
  463.  
  464.     return "\n".join(out)
  465.  
  466.  
  467. # ---------------------------------------------------------------------------
  468. # Tool-run rendering
  469. # ---------------------------------------------------------------------------
  470.  
  471. TOOL_COLORS = {
  472.     "shell": "#f59e0b",
  473.     "execute": "#a78bfa",
  474.     "write": "#60a5fa",
  475.     "edit": "#38bdf8",
  476.     "read": "#34d399",
  477.     "websearch": "#f472b6",
  478.     "webfetch": "#fb7185",
  479. }
  480. TOOL_ICONS = {
  481.     "shell": "$",
  482.     "execute": "{}",
  483.     "write": "+",
  484.     "edit": "~",
  485.     "read": "≡",
  486.     "websearch": "?",
  487.     "webfetch": "↗",
  488. }
  489. DEFAULT_TOOL_COLOR = "#94a3b8"
  490.  
  491.  
  492. def next_id(ctx, prefix: str) -> str:
  493.     counters = ctx["ids"]
  494.     counters[prefix] = counters.get(prefix, 0) + 1
  495.     return f"{prefix}-{counters[prefix]}"
  496.  
  497.  
  498. def pre_block(text, css_class: str = "code", pre_id=None) -> str:
  499.     id_attr = f' id="{pre_id}"' if pre_id else ""
  500.     return f'<pre class="{css_class}"{id_attr}>{esc(strip_ansi(text))}</pre>'
  501.  
  502.  
  503. def io_block(label, body_html, hint="", path="", cwd=None, pre_id=None, css_class="") -> str:
  504.     classes = "io" + (f" {css_class}" if css_class else "")
  505.     head = [f'<span class="io-label">{esc(label)}</span>']
  506.     if path:
  507.         head.append(
  508.             f'<span class="path" title="{attr(path)}">{esc(short_path(path, cwd))}</span>'
  509.         )
  510.     if hint:
  511.         head.append(f'<span class="hint">{esc(hint)}</span>')
  512.     if pre_id:
  513.         head.append(
  514.             f'<button class="copy" type="button" data-copy="{pre_id}" title="Copy to clipboard">Copy</button>'
  515.         )
  516.     return (
  517.         f'<div class="{classes}">'
  518.         f'<div class="io-head">{"".join(head)}</div>'
  519.         f"{body_html}"
  520.         "</div>"
  521.     )
  522.  
  523.  
  524. def kv_block(pairs) -> str:
  525.     rows = []
  526.     for key, value_html in pairs:
  527.         rows.append(
  528.             f'<div class="kv-row"><span class="kv-key">{esc(key)}</span>'
  529.             f'<span class="kv-val">{value_html}</span></div>'
  530.         )
  531.     return f'<div class="kv">{"".join(rows)}</div>'
  532.  
  533.  
  534. def diff_stats(patch: str):
  535.     additions = deletions = 0
  536.     for line in patch.splitlines():
  537.         if line.startswith("+") and not line.startswith("+++"):
  538.             additions += 1
  539.         elif line.startswith("-") and not line.startswith("---"):
  540.             deletions += 1
  541.     return additions, deletions
  542.  
  543.  
  544. def render_patch(patch, pre_id=None) -> str:
  545.     if not patch:
  546.         return ""
  547.     lines = []
  548.     for line in str(patch).splitlines():
  549.         if line.startswith("+++") or line.startswith("---") or line.startswith("Index:") or line.startswith("==="):
  550.             css_class = "diff-meta"
  551.         elif line.startswith("@@"):
  552.             css_class = "diff-hunk"
  553.         elif line.startswith("+"):
  554.             css_class = "diff-add"
  555.         elif line.startswith("-"):
  556.             css_class = "diff-del"
  557.         else:
  558.             css_class = "diff-ctx"
  559.         content = line if line else " "
  560.         lines.append(f'<span class="diff-line {css_class}">{esc(content)}</span>')
  561.     id_attr = f' id="{pre_id}"' if pre_id else ""
  562.     return f'<pre class="diff"{id_attr}>' + "".join(lines) + "</pre>"
  563.  
  564.  
  565. def _execute_summary(code: str) -> str:
  566.     """Turn a small JS execute snippet into a useful one-line summary."""
  567.     calls = []
  568.     for match in re.finditer(r"([A-Za-z_$][\w.$]*)\s*\(\s*\{([^}]*)\}", code or ""):
  569.         call_name = match.group(1).split(".")[-1]
  570.         body = match.group(2)
  571.         detail = re.search(r'(command|query|path|url)\s*:\s*"((?:[^"\\]|\\.)*)"', body)
  572.         if detail:
  573.             value = detail.group(2).replace("\\n", " ").replace('\\"', '"')
  574.             calls.append(f"{call_name}: {first_line(value, 90)}")
  575.         else:
  576.             calls.append(call_name)
  577.     if calls:
  578.         unique = list(dict.fromkeys(calls))
  579.         suffix = f" +{len(unique) - 3}" if len(unique) > 3 else ""
  580.         return "JavaScript · " + " · ".join(unique[:3]) + suffix
  581.     return "JavaScript · " + (first_line(code, 140) or "execute code")
  582.  
  583.  
  584. def tool_summary(name, tool_input, output, metadata, cwd):
  585.     """Return (short summary, full title) for a tool card."""
  586.     if name == "shell":
  587.         command = tool_input.get("command", "")
  588.         return first_line(command, 150), truncate(command, 600)
  589.     if name == "write":
  590.         path = tool_input.get("path", "")
  591.         content = tool_input.get("content", "")
  592.         lines = content.count("\n") + 1 if content else 0
  593.         summary = short_path(path, cwd) or "write file"
  594.         return f"{summary} · {lines:,} lines", path
  595.     if name == "edit":
  596.         path = tool_input.get("path", "")
  597.         summary = short_path(path, cwd) or "edit file"
  598.         additions = deletions = 0
  599.         for file_info in metadata.get("files") or []:
  600.             if isinstance(file_info, dict) and file_info.get("patch"):
  601.                 added, removed = diff_stats(file_info["patch"])
  602.                 additions += added
  603.                 deletions += removed
  604.         if additions or deletions:
  605.             summary += f" · +{additions} −{deletions}"
  606.         return summary, path
  607.     if name == "read":
  608.         path = tool_input.get("path", "")
  609.         offset = tool_input.get("offset")
  610.         limit = tool_input.get("limit")
  611.         suffix = ""
  612.         if offset and limit:
  613.             suffix = f" · lines {offset}-{int(offset) + int(limit) - 1}"
  614.         elif offset:
  615.             suffix = f" · from line {offset}"
  616.         return (short_path(path, cwd) or "read file") + suffix, path
  617.     if name == "execute":
  618.         code = tool_input.get("code", "")
  619.         return _execute_summary(code), truncate(code, 600)
  620.     if name == "websearch":
  621.         query = tool_input.get("query", "")
  622.         return first_line(query, 150) or "web search", query
  623.     if name == "webfetch":
  624.         url = tool_input.get("url", "")
  625.         return url or "fetch URL", url
  626.     try:
  627.         payload = json.dumps(tool_input, ensure_ascii=False)
  628.     except (TypeError, ValueError):
  629.         payload = str(tool_input)
  630.     return first_line(payload, 150), truncate(payload, 600)
  631.  
  632.  
  633. def tool_is_error(state) -> bool:
  634.     return bool(state.get("error")) or state.get("status") == "error"
  635.  
  636.  
  637. def tool_is_warning(name, state) -> bool:
  638.     if tool_is_error(state):
  639.         return False
  640.     metadata = state.get("metadata") or {}
  641.     if state.get("status") not in (None, "completed"):
  642.         return True
  643.     if metadata.get("timeout") or metadata.get("truncated"):
  644.         return True
  645.     if metadata.get("exit") not in (None, 0):
  646.         return True
  647.     return False
  648.  
  649.  
  650. def tool_badges(name, state):
  651.     metadata = state.get("metadata") or {}
  652.     status = state.get("status")
  653.     badges = []
  654.     if tool_is_error(state):
  655.         badges.append(("error", "error"))
  656.     elif metadata.get("timeout"):
  657.         badges.append(("timeout", "warn"))
  658.     elif status and status not in ("completed",):
  659.         badges.append((status, "warn"))
  660.     elif metadata.get("truncated"):
  661.         badges.append(("truncated", "warn"))
  662.     elif metadata.get("exit") not in (None, 0):
  663.         badges.append((f"exit {metadata['exit']}", "warn"))
  664.     elif name == "shell" and metadata.get("exit") == 0:
  665.         badges.append(("exit 0", "ok"))
  666.     else:
  667.         badges.append(("ok", "ok"))
  668.     return badges
  669.  
  670.  
  671. def _render_tool_input(name, tool_input, metadata, ctx):
  672.     cwd = ctx["cwd"]
  673.     parts = []
  674.  
  675.     if name == "shell":
  676.         command = tool_input.get("command", "")
  677.         timeout = tool_input.get("timeout")
  678.         hint = f"timeout {fmt_duration(timeout)}" if timeout else ""
  679.         pre_id = next_id(ctx, "pre")
  680.         body = (
  681.             f'<pre class="code" id="{pre_id}">'
  682.             f'<span class="prompt-char">$</span> {esc(command)}'
  683.             "</pre>"
  684.         )
  685.         parts.append(io_block("Command", body, hint=hint, pre_id=pre_id))
  686.  
  687.     elif name == "write":
  688.         path = tool_input.get("path", "")
  689.         content = tool_input.get("content", "")
  690.         lines = content.count("\n") + 1 if content else 0
  691.         hint = f"{lines:,} lines · {fmt_bytes(len(content.encode('utf-8')))}"
  692.         pre_id = next_id(ctx, "pre")
  693.         parts.append(
  694.             io_block(
  695.                 "File content",
  696.                 pre_block(content, "code", pre_id),
  697.                 hint=hint,
  698.                 path=path,
  699.                 cwd=cwd,
  700.                 pre_id=pre_id,
  701.             )
  702.         )
  703.  
  704.     elif name == "edit":
  705.         path = tool_input.get("path", "")
  706.         files = metadata.get("files") or []
  707.         rendered_diff = False
  708.         for file_info in files:
  709.             if not isinstance(file_info, dict):
  710.                 continue
  711.             patch = file_info.get("patch")
  712.             if not patch:
  713.                 continue
  714.             file_path = file_info.get("file") or path
  715.             additions, deletions = diff_stats(patch)
  716.             pre_id = next_id(ctx, "pre")
  717.             parts.append(
  718.                 io_block(
  719.                     "Diff",
  720.                     render_patch(patch, pre_id),
  721.                     hint=f"+{additions} −{deletions}",
  722.                     path=file_path,
  723.                     cwd=cwd,
  724.                     pre_id=pre_id,
  725.                 )
  726.             )
  727.             rendered_diff = True
  728.         if not rendered_diff:
  729.             for label, key in (("Old text", "oldString"), ("New text", "newString")):
  730.                 value = tool_input.get(key)
  731.                 if value:
  732.                     pre_id = next_id(ctx, "pre")
  733.                     parts.append(
  734.                         io_block(
  735.                             label,
  736.                             pre_block(value, "code", pre_id),
  737.                             hint=f"{value.count(chr(10)) + 1:,} lines",
  738.                             path=path,
  739.                             cwd=cwd,
  740.                             pre_id=pre_id,
  741.                         )
  742.                     )
  743.  
  744.     elif name == "read":
  745.         # The path and range are shown on the output block instead.
  746.         return []
  747.  
  748.     elif name == "execute":
  749.         code = tool_input.get("code", "")
  750.         if code:
  751.             pre_id = next_id(ctx, "pre")
  752.             parts.append(
  753.                 io_block(
  754.                     "Code",
  755.                     pre_block(code, "code", pre_id),
  756.                     hint="JavaScript",
  757.                     pre_id=pre_id,
  758.                 )
  759.             )
  760.         calls = metadata.get("toolCalls") or []
  761.         if calls:
  762.             call_html = []
  763.             for call in calls:
  764.                 if not isinstance(call, dict):
  765.                     continue
  766.                 call_name = call.get("tool") or call.get("name") or "tool"
  767.                 call_status = call.get("status") or ""
  768.                 call_input = call.get("input")
  769.                 try:
  770.                     input_text = json.dumps(call_input, ensure_ascii=False) if call_input else ""
  771.                 except (TypeError, ValueError):
  772.                     input_text = str(call_input or "")
  773.                 status_class = "ok" if call_status == "completed" else "warn"
  774.                 call_html.append(
  775.                     '<div class="call">'
  776.                     f'<span class="call-name">{esc(call_name)}</span>'
  777.                     f'<span class="badge {status_class}">{esc(call_status or "called")}</span>'
  778.                     f'<code>{esc(first_line(input_text, 180))}</code>'
  779.                     "</div>"
  780.                 )
  781.             if call_html:
  782.                 parts.append(
  783.                     '<div class="io"><div class="io-head">'
  784.                     f'<span class="io-label">Tool calls</span><span class="hint">{len(call_html)}</span>'
  785.                     f'</div><div class="calls">{"".join(call_html)}</div></div>'
  786.                 )
  787.  
  788.     elif name == "websearch":
  789.         query = tool_input.get("query", "")
  790.         provider = metadata.get("provider")
  791.         pre_id = next_id(ctx, "pre")
  792.         parts.append(
  793.             io_block(
  794.                 "Query",
  795.                 pre_block(query, "code", pre_id),
  796.                 hint=f"provider: {provider}" if provider else "",
  797.                 pre_id=pre_id,
  798.             )
  799.         )
  800.  
  801.     elif name == "webfetch":
  802.         url = tool_input.get("url", "")
  803.         fmt = tool_input.get("format", "")
  804.         value_html = f'<code>{esc(url)}</code>' if url else '<span class="muted">—</span>'
  805.         pairs = [("URL", value_html)]
  806.         if fmt:
  807.             pairs.append(("Format", f"<code>{esc(fmt)}</code>"))
  808.         parts.append(io_block("Request", kv_block(pairs)))
  809.  
  810.     else:
  811.         try:
  812.             payload = json.dumps(tool_input, indent=2, ensure_ascii=False)
  813.         except (TypeError, ValueError):
  814.             payload = str(tool_input)
  815.         if payload and payload != "{}":
  816.             pre_id = next_id(ctx, "pre")
  817.             parts.append(
  818.                 io_block("Input", pre_block(payload, "code", pre_id), hint="JSON", pre_id=pre_id)
  819.             )
  820.  
  821.     return parts
  822.  
  823.  
  824. def _tool_output_text(state) -> str:
  825.     content = state.get("content")
  826.     if isinstance(content, str):
  827.         return strip_ansi(content)
  828.     if not isinstance(content, list):
  829.         return ""
  830.     chunks = []
  831.     for block in content:
  832.         if isinstance(block, dict):
  833.             if block.get("type") == "text":
  834.                 chunks.append(strip_ansi(block.get("text", "")))
  835.             elif block.get("type"):
  836.                 try:
  837.                     chunks.append(json.dumps(block, indent=2, ensure_ascii=False))
  838.                 except (TypeError, ValueError):
  839.                     chunks.append(str(block))
  840.         elif block is not None:
  841.             chunks.append(str(block))
  842.     return "\n\n".join(chunk for chunk in chunks if chunk)
  843.  
  844.  
  845. def _render_tool_output(name, tool_input, state, ctx):
  846.     cwd = ctx["cwd"]
  847.     metadata = state.get("metadata") or {}
  848.     error = state.get("error")
  849.     output = _tool_output_text(state)
  850.  
  851.     if error:
  852.         if isinstance(error, dict):
  853.             message = error.get("message") or json.dumps(error, ensure_ascii=False)
  854.             error_type = error.get("type")
  855.         else:
  856.             message = str(error)
  857.             error_type = None
  858.         pre_id = next_id(ctx, "pre")
  859.         return [
  860.             io_block(
  861.                 "Error",
  862.                 pre_block(message, "output error-text", pre_id),
  863.                 hint=error_type or "",
  864.                 pre_id=pre_id,
  865.             )
  866.         ]
  867.  
  868.     label = "Output"
  869.     path = None
  870.     hint = None
  871.  
  872.     if name == "read":
  873.         label = "File contents"
  874.         path = tool_input.get("path")
  875.         offset = tool_input.get("offset")
  876.         limit = tool_input.get("limit")
  877.         if offset and limit:
  878.             hint = f"lines {offset}-{int(offset) + int(limit) - 1}"
  879.         elif offset:
  880.             hint = f"from line {offset}"
  881.     elif name in ("write", "edit"):
  882.         label = "Result"
  883.     elif name == "websearch":
  884.         label = "Results"
  885.     elif name == "webfetch":
  886.         label = "Content"
  887.  
  888.     if not output.strip():
  889.         return ['<div class="empty-note">No output captured.</div>']
  890.  
  891.     if hint is None:
  892.         line_count = output.count("\n") + 1
  893.         hint = f"{fmt_bytes(len(output.encode('utf-8')))} · {line_count:,} lines"
  894.     if metadata.get("truncated"):
  895.         hint += " · truncated"
  896.  
  897.     pre_id = next_id(ctx, "pre")
  898.     return [
  899.         io_block(
  900.             label,
  901.             pre_block(output, "output", pre_id),
  902.             hint=hint,
  903.             path=path,
  904.             cwd=cwd,
  905.             pre_id=pre_id,
  906.         )
  907.     ]
  908.  
  909.  
  910. def render_tool(tool, ctx) -> str:
  911.     name = tool.get("name") or "tool"
  912.     state = tool.get("state") or {}
  913.     if not isinstance(state, dict):
  914.         state = {"status": "completed", "content": [{"type": "text", "text": str(state)}]}
  915.     tool_input = state.get("input") or {}
  916.     if not isinstance(tool_input, dict):
  917.         tool_input = {"value": tool_input}
  918.     metadata = state.get("metadata") or {}
  919.     if not isinstance(metadata, dict):
  920.         metadata = {}
  921.  
  922.     status = state.get("status") or "completed"
  923.     is_error = tool_is_error(state)
  924.     is_warning = tool_is_warning(name, state)
  925.     is_issue = is_error or is_warning
  926.  
  927.     summary, title = tool_summary(name, tool_input, _tool_output_text(state), metadata, ctx["cwd"])
  928.     duration = None
  929.     timing = tool.get("time") or {}
  930.     if timing.get("completed") and timing.get("created"):
  931.         duration = int(timing["completed"]) - int(timing["created"])
  932.     elif timing.get("ran") and timing.get("created"):
  933.         duration = int(timing["ran"]) - int(timing["created"])
  934.  
  935.     badges = tool_badges(name, state)
  936.     badge_html = "".join(
  937.         f'<span class="badge {css_class}">{esc(label)}</span>' for label, css_class in badges
  938.     )
  939.     if duration is not None:
  940.         badge_html += f'<span class="dur" title="{duration} ms">{esc(fmt_duration(duration))}</span>'
  941.  
  942.     body_parts = []
  943.     body_parts.extend(_render_tool_input(name, tool_input, metadata, ctx))
  944.     body_parts.extend(_render_tool_output(name, tool_input, state, ctx))
  945.     if not body_parts:
  946.         body_parts.append('<div class="empty-note">No details captured.</div>')
  947.  
  948.     accent = TOOL_COLORS.get(name, DEFAULT_TOOL_COLOR)
  949.     icon = TOOL_ICONS.get(name, "•")
  950.     tool_id = next_id(ctx, "tool")
  951.  
  952.     return (
  953.         f'<details class="tool" id="{tool_id}" data-kind="tool" data-tool="{attr(name)}" '
  954.         f'data-status="{attr(status)}" data-issue="{1 if is_issue else 0}" '
  955.         f'data-error="{1 if is_error else 0}" data-warning="{1 if is_warning else 0}" '
  956.         f'style="--accent:{accent}">'
  957.         "<summary>"
  958.         '<span class="chev" aria-hidden="true">›</span>'
  959.         f'<span class="tool-icon" aria-hidden="true">{esc(icon)}</span>'
  960.         f'<span class="tool-name">{esc(name)}</span>'
  961.         f'<span class="tool-summary" title="{attr(truncate(title, 600))}">{esc(summary)}</span>'
  962.         f'<span class="tool-badges">{badge_html}</span>'
  963.         "</summary>"
  964.         f'<div class="tool-body">{"".join(body_parts)}</div>'
  965.         "</details>"
  966.     )
  967.  
  968.  
  969. # ---------------------------------------------------------------------------
  970. # Conversation rendering
  971. # ---------------------------------------------------------------------------
  972.  
  973. def _time_tag(ms, ctx, label=None, css_class="time") -> str:
  974.     if not ms:
  975.         return ""
  976.     text = label if label is not None else fmt_elapsed(ms, ctx.get("start_ms"))
  977.     if not text:
  978.         text = fmt_date(ms, short=True)
  979.     return (
  980.         f'<time class="{css_class}" datetime="{attr(fmt_datetime_attr(ms))}" '
  981.         f'title="{attr(fmt_date(ms))}">{esc(text)}</time>'
  982.     )
  983.  
  984.  
  985. def _anchor(element_id: str) -> str:
  986.     return f'<a class="anchor" href="#{element_id}" title="Permalink">#</a>'
  987.  
  988.  
  989. def render_attachments(msg, ctx) -> str:
  990.     files = msg.get("files") or []
  991.     attachments = (msg.get("metadata") or {}).get("attachments") or []
  992.     if not files and not attachments:
  993.         return ""
  994.     payload = {"files": files, "attachments": attachments}
  995.     try:
  996.         text = json.dumps(payload, indent=2, ensure_ascii=False)
  997.     except (TypeError, ValueError):
  998.         text = str(payload)
  999.     count = len(files) + len(attachments)
  1000.     pre_id = next_id(ctx, "attach")
  1001.     return (
  1002.         '<details class="attachments">'
  1003.         f'<summary><span class="chev" aria-hidden="true">›</span>'
  1004.         f'Attachments ({count})</summary>'
  1005.         f'<pre class="code" id="{pre_id}">{esc(text)}</pre>'
  1006.         "</details>"
  1007.     )
  1008.  
  1009.  
  1010. def render_prompt(msg, number: int, ctx) -> str:
  1011.     metadata = msg.get("metadata") or {}
  1012.     text = msg.get("text") or metadata.get("displayText") or ""
  1013.     created = (msg.get("time") or {}).get("created")
  1014.     agent = metadata.get("agent") or msg.get("agent")
  1015.     prompt_id = f"prompt-{number}"
  1016.  
  1017.     head = [
  1018.         '<span class="avatar you">You</span>',
  1019.         f'<span class="prompt-label">Prompt {number}<span class="of"> / {ctx["prompt_count"]}</span></span>',
  1020.     ]
  1021.     if agent:
  1022.         head.append(f'<span class="tiny-pill">{esc(agent)}</span>')
  1023.     time_html = _time_tag(created, ctx)
  1024.     if time_html:
  1025.         head.append(time_html)
  1026.     head.append(_anchor(prompt_id))
  1027.  
  1028.     body = render_markdown(text) if text.strip() else '<div class="empty-note">(empty prompt)</div>'
  1029.     return (
  1030.         f'<div class="prompt" id="{prompt_id}" data-kind="prompt">'
  1031.         f'<div class="prompt-head">{"".join(head)}</div>'
  1032.         f'<div class="md prompt-md">{body}</div>'
  1033.         f"{render_attachments(msg, ctx)}"
  1034.         "</div>"
  1035.     )
  1036.  
  1037.  
  1038. def render_response(block, msg, ctx) -> str:
  1039.     text = block.get("text") or ""
  1040.     if not text.strip():
  1041.         return ""
  1042.     created = (block.get("time") or {}).get("created") or (msg.get("time") or {}).get("created")
  1043.     model = (msg.get("model") or {}).get("id") or (msg.get("model") or {}).get("modelID") or ""
  1044.     response_id = next_id(ctx, "resp")
  1045.  
  1046.     head = [
  1047.         '<span class="avatar ai">AI</span>',
  1048.         '<span class="who">Assistant</span>',
  1049.     ]
  1050.     if model:
  1051.         head.append(f'<span class="who-sub">{esc(model)}</span>')
  1052.     time_html = _time_tag(created, ctx)
  1053.     if time_html:
  1054.         head.append(time_html)
  1055.     head.append(_anchor(response_id))
  1056.  
  1057.     return (
  1058.         f'<div class="response" id="{response_id}" data-kind="response">'
  1059.         f'<div class="response-head">{"".join(head)}</div>'
  1060.         f'<div class="md">{render_markdown(text)}</div>'
  1061.         "</div>"
  1062.     )
  1063.  
  1064.  
  1065. def render_reasoning(block, msg, ctx) -> str:
  1066.     text = block.get("text") or ""
  1067.     if not text.strip():
  1068.         return ""
  1069.     timing = block.get("time") or {}
  1070.     created = timing.get("created") or (msg.get("time") or {}).get("created")
  1071.     duration = None
  1072.     if timing.get("completed") and timing.get("created"):
  1073.         duration = int(timing["completed"]) - int(timing["created"])
  1074.     reason_id = next_id(ctx, "reason")
  1075.  
  1076.     summary_bits = [
  1077.         '<span class="chev" aria-hidden="true">›</span>',
  1078.         '<span class="reason-label">Reasoning</span>',
  1079.     ]
  1080.     if duration is not None:
  1081.         summary_bits.append(f'<span class="hint">{esc(fmt_duration(duration))}</span>')
  1082.     time_html = _time_tag(created, ctx)
  1083.     if time_html:
  1084.         summary_bits.append(time_html)
  1085.  
  1086.     return (
  1087.         f'<details class="reasoning" id="{reason_id}" data-kind="reasoning">'
  1088.         f'<summary>{"".join(summary_bits)}</summary>'
  1089.         f'<div class="reasoning-body md">{render_markdown(text)}</div>'
  1090.         "</details>"
  1091.     )
  1092.  
  1093.  
  1094. def render_compaction(msg, ctx) -> str:
  1095.     summary = msg.get("summary") or ""
  1096.     reason = msg.get("reason") or "compaction"
  1097.     status = msg.get("status") or "completed"
  1098.     created = (msg.get("time") or {}).get("created")
  1099.     compact_id = next_id(ctx, "compact")
  1100.  
  1101.     bits = [
  1102.         '<span class="chev" aria-hidden="true">›</span>',
  1103.         '<span class="compact-icon" aria-hidden="true">⇣</span>',
  1104.         '<span class="compact-title">Context compacted</span>',
  1105.         f'<span class="badge {"ok" if status == "completed" else "warn"}">{esc(reason)}</span>',
  1106.     ]
  1107.     if summary:
  1108.         bits.append(f'<span class="hint">{esc(fmt_bytes(len(summary.encode("utf-8"))))} summary</span>')
  1109.     time_html = _time_tag(created, ctx)
  1110.     if time_html:
  1111.         bits.append(time_html)
  1112.  
  1113.     body = render_markdown(summary) if summary.strip() else '<div class="empty-note">No summary captured.</div>'
  1114.     return (
  1115.         f'<details class="compaction" id="{compact_id}" data-kind="event">'
  1116.         f'<summary>{"".join(bits)}</summary>'
  1117.         '<div class="compaction-body">'
  1118.         '<p class="compaction-note">The session context was compacted. This is the summary that replaced the older messages.</p>'
  1119.         f'<div class="md">{body}</div>'
  1120.         "</div>"
  1121.         "</details>"
  1122.     )
  1123.  
  1124.  
  1125. def render_unknown_event(msg, ctx) -> str:
  1126.     try:
  1127.         payload = json.dumps(msg, indent=2, ensure_ascii=False)
  1128.     except (TypeError, ValueError):
  1129.         payload = str(msg)
  1130.     event_id = next_id(ctx, "event")
  1131.     return (
  1132.         f'<details class="unknown-event" id="{event_id}" data-kind="event">'
  1133.         '<summary><span class="chev" aria-hidden="true">›</span>'
  1134.         f'<span class="reason-label">Event: {esc(msg.get("type") or "unknown")}</span></summary>'
  1135.         f'<pre class="code">{esc(payload)}</pre>'
  1136.         "</details>"
  1137.     )
  1138.  
  1139.  
  1140. def render_unknown_content(block, msg, ctx) -> str:
  1141.     try:
  1142.         payload = json.dumps(block, indent=2, ensure_ascii=False)
  1143.     except (TypeError, ValueError):
  1144.         payload = str(block)
  1145.     return (
  1146.         '<div class="unknown-content">'
  1147.         f'<div class="empty-note">Unknown content block: {esc(block.get("type") or "unknown")}</div>'
  1148.         f'<pre class="code">{esc(payload)}</pre>'
  1149.         "</div>"
  1150.     )
  1151.  
  1152.  
  1153. def render_message(msg, ctx) -> str:
  1154.     msg_type = msg.get("type")
  1155.     if msg_type == "assistant":
  1156.         parts = []
  1157.         for block in msg.get("content") or []:
  1158.             block_type = block.get("type") if isinstance(block, dict) else None
  1159.             if block_type == "text":
  1160.                 parts.append(render_response(block, msg, ctx))
  1161.             elif block_type == "reasoning":
  1162.                 parts.append(render_reasoning(block, msg, ctx))
  1163.             elif block_type == "tool":
  1164.                 parts.append(render_tool(block, ctx))
  1165.             elif block_type:
  1166.                 parts.append(render_unknown_content(block, msg, ctx))
  1167.         return "".join(part for part in parts if part)
  1168.     if msg_type == "compaction":
  1169.         return render_compaction(msg, ctx)
  1170.     if msg_type == "idle":
  1171.         return ""
  1172.     if msg_type == "user":
  1173.         return ""
  1174.     return render_unknown_event(msg, ctx)
  1175.  
  1176.  
  1177. def group_turns(messages):
  1178.     turns = []
  1179.     current = None
  1180.     for msg in messages:
  1181.         if not isinstance(msg, dict):
  1182.             continue
  1183.         if msg.get("type") == "user":
  1184.             current = {"prompt": msg, "events": []}
  1185.             turns.append(current)
  1186.         else:
  1187.             if current is None:
  1188.                 current = {"prompt": None, "events": []}
  1189.                 turns.append(current)
  1190.             current["events"].append(msg)
  1191.     return turns
  1192.  
  1193.  
  1194. def render_turn(turn, number: int, ctx) -> str:
  1195.     parts = [f'<section class="turn" id="turn-{number}" data-turn="{number}">']
  1196.     prompt = turn.get("prompt")
  1197.     if prompt:
  1198.         parts.append(render_prompt(prompt, number, ctx))
  1199.     else:
  1200.         parts.append(
  1201.             '<div class="turn-start"><span class="avatar ai">AI</span>'
  1202.             '<span class="muted">Output before the first prompt</span></div>'
  1203.         )
  1204.     body = "".join(render_message(msg, ctx) for msg in turn.get("events") or [])
  1205.     parts.append(f'<div class="turn-body">{body}</div>')
  1206.     parts.append("</section>")
  1207.     return "".join(parts)
  1208.  
  1209.  
  1210. # ---------------------------------------------------------------------------
  1211. # Session statistics and page assembly
  1212. # ---------------------------------------------------------------------------
  1213.  
  1214. def _msg_created(msg):
  1215.     timing = msg.get("time") or {}
  1216.     return timing.get("created") or timing.get("updated") or timing.get("completed")
  1217.  
  1218.  
  1219. def _sum_tokens(messages, key: str) -> int:
  1220.     total = 0
  1221.     for msg in messages:
  1222.         tokens = msg.get("tokens") or {}
  1223.         if key == "cache_read":
  1224.             total += int((tokens.get("cache") or {}).get("read") or 0)
  1225.         else:
  1226.             total += int(tokens.get(key) or 0)
  1227.     return total
  1228.  
  1229.  
  1230. def collect_stats(info, messages, source_size=None):
  1231.     info = info if isinstance(info, dict) else {}
  1232.     messages = [msg for msg in messages if isinstance(msg, dict)]
  1233.  
  1234.     user_messages = [msg for msg in messages if msg.get("type") == "user"]
  1235.     assistant_messages = [msg for msg in messages if msg.get("type") == "assistant"]
  1236.     content_blocks = [
  1237.         block
  1238.         for msg in assistant_messages
  1239.         for block in (msg.get("content") or [])
  1240.         if isinstance(block, dict)
  1241.     ]
  1242.     tools = [block for block in content_blocks if block.get("type") == "tool"]
  1243.     text_blocks = [
  1244.         block
  1245.         for block in content_blocks
  1246.         if block.get("type") == "text" and (block.get("text") or "").strip()
  1247.     ]
  1248.     reasoning_blocks = [
  1249.         block
  1250.         for block in content_blocks
  1251.         if block.get("type") == "reasoning" and (block.get("text") or "").strip()
  1252.     ]
  1253.     compactions = [msg for msg in messages if msg.get("type") == "compaction"]
  1254.  
  1255.     tool_counts = Counter(block.get("name") or "tool" for block in tools)
  1256.     error_count = sum(1 for block in tools if tool_is_error(block.get("state") or {}))
  1257.     warning_count = sum(
  1258.         1
  1259.         for block in tools
  1260.         if tool_is_warning(block.get("name") or "tool", block.get("state") or {})
  1261.     )
  1262.     issue_count = error_count + warning_count
  1263.  
  1264.     info_time = info.get("time") or {}
  1265.     message_times = [time for time in (_msg_created(msg) for msg in messages) if time]
  1266.     start_ms = info_time.get("created") or (min(message_times) if message_times else None)
  1267.     end_ms = info_time.get("updated") or (max(message_times) if message_times else None)
  1268.     duration_ms = None
  1269.     if start_ms and end_ms:
  1270.         duration_ms = max(0, int(end_ms) - int(start_ms))
  1271.  
  1272.     tokens = info.get("tokens") if isinstance(info.get("tokens"), dict) else {}
  1273.     input_tokens = tokens.get("input")
  1274.     if input_tokens is None:
  1275.         input_tokens = _sum_tokens(assistant_messages, "input")
  1276.     output_tokens = tokens.get("output")
  1277.     if output_tokens is None:
  1278.         output_tokens = _sum_tokens(assistant_messages, "output")
  1279.     reasoning_tokens = tokens.get("reasoning")
  1280.     if reasoning_tokens is None:
  1281.         reasoning_tokens = _sum_tokens(assistant_messages, "reasoning")
  1282.     cache_read = (tokens.get("cache") or {}).get("read")
  1283.     if cache_read is None:
  1284.         cache_read = _sum_tokens(assistant_messages, "cache_read")
  1285.  
  1286.     cost = info.get("cost")
  1287.     if cost is None:
  1288.         cost = sum(float(msg.get("cost") or 0) for msg in assistant_messages)
  1289.  
  1290.     model_info = info.get("model") or {}
  1291.     if not isinstance(model_info, dict) or not (model_info.get("id") or model_info.get("modelID")):
  1292.         for msg in assistant_messages:
  1293.             candidate = msg.get("model") or {}
  1294.             if isinstance(candidate, dict) and (candidate.get("id") or candidate.get("modelID")):
  1295.                 model_info = candidate
  1296.                 break
  1297.     model_id = model_info.get("id") or model_info.get("modelID") or "—"
  1298.     model_provider = model_info.get("providerID") or ""
  1299.  
  1300.     agent = info.get("agent")
  1301.     if not agent:
  1302.         for msg in assistant_messages:
  1303.             if msg.get("agent"):
  1304.                 agent = msg["agent"]
  1305.                 break
  1306.     agent = agent or "—"
  1307.  
  1308.     outcome = info.get("outcome")
  1309.     if not outcome:
  1310.         idle_messages = [msg for msg in messages if msg.get("type") == "idle" and msg.get("outcome")]
  1311.         outcome = idle_messages[-1]["outcome"] if idle_messages else "completed"
  1312.  
  1313.     location = info.get("location") or {}
  1314.     cwd = location.get("directory") if isinstance(location, dict) else None
  1315.     session_id = info.get("id") or "—"
  1316.  
  1317.     return {
  1318.         "messages": messages,
  1319.         "message_count": len(messages),
  1320.         "prompt_count": len(user_messages),
  1321.         "response_count": len(text_blocks),
  1322.         "reasoning_count": len(reasoning_blocks),
  1323.         "tool_total": len(tools),
  1324.         "tool_counts": tool_counts,
  1325.         "event_count": len(compactions),
  1326.         "total_blocks": len(user_messages) + len(text_blocks) + len(tools) + len(reasoning_blocks) + len(compactions),
  1327.         "error_count": error_count,
  1328.         "warning_count": warning_count,
  1329.         "issue_count": issue_count,
  1330.         "start_ms": start_ms,
  1331.         "end_ms": end_ms,
  1332.         "duration_ms": duration_ms,
  1333.         "input_tokens": int(input_tokens or 0),
  1334.         "output_tokens": int(output_tokens or 0),
  1335.         "reasoning_tokens": int(reasoning_tokens or 0),
  1336.         "cache_read": int(cache_read or 0),
  1337.         "cost": cost,
  1338.         "model_id": model_id,
  1339.         "model_provider": model_provider,
  1340.         "agent": agent,
  1341.         "outcome": outcome,
  1342.         "cwd": cwd or "",
  1343.         "session_id": session_id,
  1344.         "source_size": source_size,
  1345.     }
  1346.  
  1347.  
  1348. def _stat_card(label, value, sub="", tone="") -> str:
  1349.     sub_html = f'<span class="stat-sub">{esc(sub)}</span>' if sub else ""
  1350.     tone_class = f" {tone}" if tone else ""
  1351.     return (
  1352.         f'<div class="stat{tone_class}">'
  1353.         f'<span class="stat-label">{esc(label)}</span>'
  1354.         f'<span class="stat-value">{esc(value)}</span>'
  1355.         f"{sub_html}"
  1356.         "</div>"
  1357.     )
  1358.  
  1359.  
  1360. def render_hero(stats, title) -> str:
  1361.     outcome = str(stats["outcome"])
  1362.     outcome_lower = outcome.lower()
  1363.     if outcome_lower in ("succeeded", "success", "completed"):
  1364.         outcome_tone = "ok"
  1365.     elif "fail" in outcome_lower or "error" in outcome_lower:
  1366.         outcome_tone = "error"
  1367.     else:
  1368.         outcome_tone = "warn"
  1369.  
  1370.     pills = [
  1371.         f'<span class="pill model" title="{attr(stats["model_provider"])}">{esc(stats["model_id"])}</span>',
  1372.         f'<span class="pill">agent: {esc(stats["agent"])}</span>',
  1373.         f'<span class="pill outcome {outcome_tone}">{esc(outcome)}</span>',
  1374.     ]
  1375.     if stats["cwd"]:
  1376.         pills.append(
  1377.             f'<span class="pill mono" title="{attr(stats["cwd"])}">{esc(truncate(stats["cwd"], 70))}</span>'
  1378.         )
  1379.  
  1380.     issues_sub = f'{stats["error_count"]} error{"" if stats["error_count"] == 1 else "s"} · {stats["warning_count"]} warning{"" if stats["warning_count"] == 1 else "s"}'
  1381.     token_sub = f'{fmt_int(stats["input_tokens"])} in · {fmt_int(stats["output_tokens"])} out'
  1382.     if stats["cache_read"]:
  1383.         token_sub += f' · {short_num(stats["cache_read"])} cache'
  1384.  
  1385.     cards = [
  1386.         _stat_card("Duration", fmt_duration(stats["duration_ms"]) or "—", fmt_date(stats["start_ms"], short=True)),
  1387.         _stat_card("Prompts", f'{stats["prompt_count"]:,}', "user turns"),
  1388.         _stat_card("Responses", f'{stats["response_count"]:,}', "assistant text blocks"),
  1389.         _stat_card("Tool runs", f'{stats["tool_total"]:,}', f'{len(stats["tool_counts"])} tool kinds'),
  1390.         _stat_card("Tokens", f'{compact_num(stats["input_tokens"])} / {compact_num(stats["output_tokens"])}', token_sub),
  1391.         _stat_card("Cost", fmt_cost(stats["cost"]), "session cost"),
  1392.         _stat_card("Issues", f'{stats["issue_count"]:,}', issues_sub, "error" if stats["error_count"] else ("warn" if stats["warning_count"] else "")),
  1393.     ]
  1394.  
  1395.     mix_pills = []
  1396.     for name, count in sorted(stats["tool_counts"].items(), key=lambda item: (-item[1], item[0])):
  1397.         accent = TOOL_COLORS.get(name, DEFAULT_TOOL_COLOR)
  1398.         mix_pills.append(
  1399.             f'<span class="mix-pill" style="--accent:{accent}">'
  1400.             '<span class="mix-dot" aria-hidden="true"></span>'
  1401.             f'<span class="mix-name">{esc(name)}</span>'
  1402.             f'<b>{count:,}</b>'
  1403.             "</span>"
  1404.         )
  1405.     mix_html = (
  1406.         '<div class="tool-mix"><span class="mix-label">Tool mix</span>'
  1407.         + "".join(mix_pills)
  1408.         + "</div>"
  1409.         if mix_pills
  1410.         else ""
  1411.     )
  1412.  
  1413.     meta_bits = []
  1414.     if stats["start_ms"]:
  1415.         meta_bits.append(f'Started {esc(fmt_date(stats["start_ms"]))}')
  1416.     if stats["end_ms"]:
  1417.         meta_bits.append(f'Ended {esc(fmt_date(stats["end_ms"]))}')
  1418.     meta_bits.append(f'{stats["message_count"]:,} messages')
  1419.     if stats["source_size"]:
  1420.         meta_bits.append(f'{fmt_bytes(stats["source_size"])} log')
  1421.     meta_bits.append(f'Session {stats["session_id"]}')
  1422.     meta_html = '<span class="sep">·</span>'.join(f"<span>{bit}</span>" for bit in meta_bits)
  1423.  
  1424.     return (
  1425.         '<header class="hero">'
  1426.         '<div class="hero-main">'
  1427.         '<div class="eyebrow"><span class="eyebrow-dot" aria-hidden="true"></span>OpenCode session log</div>'
  1428.         f"<h1>{esc(title)}</h1>"
  1429.         f'<div class="subline">{"".join(pills)}</div>'
  1430.         f'<div class="hero-meta">{meta_html}</div>'
  1431.         "</div>"
  1432.         f'<div class="stats">{"".join(cards)}</div>'
  1433.         f"{mix_html}"
  1434.         "</header>"
  1435.     )
  1436.  
  1437.  
  1438. def render_toolbar(stats) -> str:
  1439.     chips = [
  1440.         ("all", "All", stats["total_blocks"]),
  1441.         ("prompt", "Prompts", stats["prompt_count"]),
  1442.         ("response", "Responses", stats["response_count"]),
  1443.         ("tool", "Tool runs", stats["tool_total"]),
  1444.         ("reasoning", "Reasoning", stats["reasoning_count"]),
  1445.         ("issue", "Issues", stats["issue_count"]),
  1446.     ]
  1447.     chip_html = []
  1448.     for value, label, count in chips:
  1449.         active = " active" if value == "all" else ""
  1450.         pressed = "true" if value == "all" else "false"
  1451.         chip_html.append(
  1452.             f'<button class="chip{active}" type="button" data-filter="{value}" '
  1453.             f'aria-pressed="{pressed}">{esc(label)}<span class="count">{count:,}</span></button>'
  1454.         )
  1455.  
  1456.     return (
  1457.         '<div class="toolbar" role="region" aria-label="Session controls">'
  1458.         '<div class="toolbar-inner">'
  1459.         f'<div class="filters" role="group" aria-label="Filter blocks">{"".join(chip_html)}</div>'
  1460.         '<div class="toolbar-actions">'
  1461.         '<label class="search" for="search">'
  1462.         '<span class="search-icon" aria-hidden="true">⌕</span>'
  1463.         '<input id="search" type="search" placeholder="Search session…  (press /)" autocomplete="off" spellcheck="false">'
  1464.         "</label>"
  1465.         f'<span class="visible-count" id="visible-count" aria-live="polite">{stats["total_blocks"]:,} blocks</span>'
  1466.         '<button class="btn" type="button" data-expand="all">Expand all</button>'
  1467.         '<button class="btn" type="button" data-expand="none">Collapse all</button>'
  1468.         '<button class="icon-btn" id="theme-toggle" type="button" aria-label="Toggle theme" title="Toggle theme">☀</button>'
  1469.         "</div>"
  1470.         "</div>"
  1471.         "</div>"
  1472.     )
  1473.  
  1474.  
  1475. def default_title(messages) -> str:
  1476.     for msg in messages:
  1477.         if not isinstance(msg, dict) or msg.get("type") != "user":
  1478.             continue
  1479.         text = msg.get("text") or (msg.get("metadata") or {}).get("displayText") or ""
  1480.         first = text.strip().splitlines()[0].strip() if text.strip() else ""
  1481.         first = re.sub(r"^#+\s*", "", first)
  1482.         if first:
  1483.             return truncate(first, 100)
  1484.     return "OpenCode session"
  1485.  
  1486.  
  1487. def _html_head(title: str) -> str:
  1488.     return (
  1489.         "<!doctype html>\n"
  1490.         '<html lang="en">\n'
  1491.         "<head>\n"
  1492.         '<meta charset="utf-8">\n'
  1493.         '<meta name="viewport" content="width=device-width, initial-scale=1">\n'
  1494.         f"<title>{esc(title)}</title>\n"
  1495.         "<script>\n"
  1496.         "try {\n"
  1497.         "  const stored = localStorage.getItem('oc-theme');\n"
  1498.         "  const theme = stored || (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');\n"
  1499.         "  document.documentElement.dataset.theme = theme;\n"
  1500.         "} catch (e) {}\n"
  1501.         "</script>\n"
  1502.         "<style>\n"
  1503.         + CSS
  1504.         + "\n</style>\n"
  1505.         "</head>\n"
  1506.         '<body id="top">\n'
  1507.         '<a class="skip-link" href="#content">Skip to content</a>\n'
  1508.     )
  1509.  
  1510.  
  1511. def _html_tail() -> str:
  1512.     return "\n<script>\n" + JS + "\n</script>\n</body>\n</html>\n"
  1513.  
  1514.  
  1515. def render_page(stats, turns_html, title, source_name, source_size) -> str:
  1516.     generated = datetime.now().astimezone().strftime("%d %b %Y, %H:%M %Z")
  1517.     if turns_html:
  1518.         main_body = "".join(turns_html)
  1519.         empty = (
  1520.             '<div id="empty" class="empty-state" hidden>'
  1521.             "No blocks match the current filter or search."
  1522.             "</div>"
  1523.         )
  1524.     else:
  1525.         main_body = '<div class="empty-state">No messages found in this session log.</div>'
  1526.         empty = ""
  1527.  
  1528.     source_bits = f"<code>{esc(source_name)}</code>"
  1529.     if source_size:
  1530.         source_bits += f" · {fmt_bytes(source_size)}"
  1531.     footer = (
  1532.         '<footer class="footer">'
  1533.         f"<div>Generated from {source_bits} · {esc(generated)}</div>"
  1534.         f'<div>Session <code>{esc(stats["session_id"])}</code> · {stats["message_count"]:,} messages · '
  1535.         '<a href="#top">Back to top ↑</a></div>'
  1536.         "</footer>"
  1537.     )
  1538.  
  1539.     return (
  1540.         _html_head(f"{title} · OpenCode session")
  1541.         + render_hero(stats, title)
  1542.         + render_toolbar(stats)
  1543.         + '<main id="content">'
  1544.         + main_body
  1545.         + empty
  1546.         + "</main>"
  1547.         + '<noscript><div class="noscript">Filters and search need JavaScript. All content is shown below.</div></noscript>'
  1548.         + footer
  1549.         + _html_tail()
  1550.     )
  1551.  
  1552.  
  1553. def render_session(data, title=None, source_name=None, source_size=None) -> str:
  1554.     if isinstance(data, list):
  1555.         info = {}
  1556.         messages = data
  1557.     elif isinstance(data, dict):
  1558.         info = data.get("info") or {}
  1559.         messages = data.get("messages") or []
  1560.     else:
  1561.         raise ValueError("Expected a session object or a list of messages")
  1562.  
  1563.     if not isinstance(messages, list):
  1564.         raise ValueError("Session 'messages' is not a list")
  1565.     if not isinstance(info, dict):
  1566.         info = {}
  1567.  
  1568.     stats = collect_stats(info, messages, source_size=source_size)
  1569.     ctx = {
  1570.         "ids": {},
  1571.         "start_ms": stats["start_ms"],
  1572.         "cwd": stats["cwd"],
  1573.         "prompt_count": stats["prompt_count"],
  1574.         "model": stats["model_id"],
  1575.     }
  1576.     turns = group_turns(messages)
  1577.     turns_html = [render_turn(turn, index, ctx) for index, turn in enumerate(turns, start=1)]
  1578.     page_title = title or default_title(messages)
  1579.     return render_page(
  1580.         stats,
  1581.         turns_html,
  1582.         page_title,
  1583.         source_name or "session log",
  1584.         source_size,
  1585.     )
  1586.  
  1587.  
  1588. def main(argv=None) -> int:
  1589.     parser = argparse.ArgumentParser(
  1590.         description="Render an OpenCode session JSON log as a single self-contained HTML file."
  1591.     )
  1592.     parser.add_argument(
  1593.         "input",
  1594.         nargs="?",
  1595.         default="opencode-sesion-log.json",
  1596.         help="session JSON file (use - for stdin)",
  1597.     )
  1598.     parser.add_argument("-o", "--output", help="output HTML file (use - for stdout)")
  1599.     parser.add_argument("--title", help="page title (defaults to the first prompt)")
  1600.     parser.add_argument("--open", action="store_true", help="open the generated page in a browser")
  1601.     args = parser.parse_args(argv)
  1602.  
  1603.     try:
  1604.         if args.input == "-":
  1605.             raw = sys.stdin.read()
  1606.             source_name = "stdin"
  1607.             source_size = len(raw.encode("utf-8"))
  1608.         else:
  1609.             input_path = Path(args.input)
  1610.             raw = input_path.read_text(encoding="utf-8")
  1611.             source_name = input_path.name
  1612.             source_size = input_path.stat().st_size
  1613.         data = json.loads(raw)
  1614.         page = render_session(
  1615.             data,
  1616.             title=args.title,
  1617.             source_name=source_name,
  1618.             source_size=source_size,
  1619.         )
  1620.     except (OSError, ValueError, json.JSONDecodeError) as exc:
  1621.         print(f"error: {exc}", file=sys.stderr)
  1622.         return 2
  1623.  
  1624.     if args.output == "-":
  1625.         sys.stdout.write(page)
  1626.         return 0
  1627.  
  1628.     if args.output:
  1629.         output_path = Path(args.output)
  1630.     elif args.input == "-":
  1631.         output_path = Path("opencode-session.html")
  1632.     else:
  1633.         output_path = Path(args.input).with_name("opencode-session.html")
  1634.  
  1635.     try:
  1636.         output_path.write_text(page, encoding="utf-8")
  1637.     except OSError as exc:
  1638.         print(f"error: {exc}", file=sys.stderr)
  1639.         return 2
  1640.  
  1641.     print(f"Wrote {output_path} ({fmt_bytes(len(page.encode('utf-8')))})")
  1642.     if args.open:
  1643.         webbrowser.open(output_path.resolve().as_uri())
  1644.     return 0
  1645.  
  1646.  
  1647. # ---------------------------------------------------------------------------
  1648. # Styles
  1649. # ---------------------------------------------------------------------------
  1650.  
  1651. CSS = r"""
  1652. :root {
  1653.  color-scheme: dark;
  1654.  --sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  1655.  --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
  1656.  --bg: #0a0d13;
  1657.  --bg-soft: #0e121a;
  1658.  --panel: #111722;
  1659.  --panel-2: #151c29;
  1660.  --panel-3: #1a2231;
  1661.  --line: #232c3d;
  1662.  --line-strong: #33405a;
  1663.  --fg: #e7ebf3;
  1664.  --muted: #99a4b8;
  1665.  --faint: #6f7b90;
  1666.  --accent: #7dd3fc;
  1667.  --accent-2: #a78bfa;
  1668.  --green: #34d399;
  1669.  --red: #fb7185;
  1670.  --amber: #fbbf24;
  1671.  --blue: #60a5fa;
  1672.  --pink: #f472b6;
  1673.  --code-bg: #080b10;
  1674.  --mark: #fde68a;
  1675.  --mark-fg: #1a2231;
  1676.  --shadow: 0 18px 50px rgba(0, 0, 0, .28);
  1677. }
  1678. :root[data-theme="light"] {
  1679.  color-scheme: light;
  1680.  --bg: #f5f7fb;
  1681.  --bg-soft: #eef2f8;
  1682.  --panel: #ffffff;
  1683.  --panel-2: #f8fafc;
  1684.  --panel-3: #eef2f7;
  1685.  --line: #e2e8f0;
  1686.  --line-strong: #cbd5e1;
  1687.  --fg: #172033;
  1688.  --muted: #5b6b82;
  1689.  --faint: #8794a8;
  1690.  --accent: #0284c7;
  1691.  --accent-2: #7c3aed;
  1692.  --green: #059669;
  1693.  --red: #e11d48;
  1694.  --amber: #b45309;
  1695.  --blue: #2563eb;
  1696.  --pink: #db2777;
  1697.  --code-bg: #f1f5f9;
  1698.  --mark: #fef08a;
  1699.  --mark-fg: #172033;
  1700.  --shadow: 0 18px 50px rgba(15, 23, 42, .08);
  1701. }
  1702. * { box-sizing: border-box; }
  1703. html { scroll-behavior: smooth; }
  1704. body {
  1705.  margin: 0;
  1706.  background-color: var(--bg);
  1707.  background-image:
  1708.    radial-gradient(1200px 620px at 12% -12%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 60%),
  1709.    radial-gradient(900px 520px at 100% 0%, color-mix(in srgb, var(--accent-2) 10%, transparent), transparent 58%);
  1710.  background-attachment: fixed;
  1711.  color: var(--fg);
  1712.  font-family: var(--sans);
  1713.  font-size: 15px;
  1714.  line-height: 1.6;
  1715.  -webkit-font-smoothing: antialiased;
  1716.  text-rendering: optimizeLegibility;
  1717. }
  1718. ::selection { background: color-mix(in srgb, var(--accent) 35%, transparent); }
  1719. a { color: inherit; }
  1720. summary { list-style: none; cursor: pointer; }
  1721. summary::-webkit-details-marker { display: none; }
  1722. .skip-link {
  1723.  position: absolute;
  1724.  left: -9999px;
  1725.  top: 0;
  1726.  z-index: 100;
  1727.  background: var(--panel);
  1728.  color: var(--fg);
  1729.  padding: 10px 14px;
  1730.  border-radius: 0 0 8px 0;
  1731.  border: 1px solid var(--line);
  1732. }
  1733. .skip-link:focus { left: 0; }
  1734.  
  1735. .hero { max-width: 1120px; margin: 0 auto; padding: 56px 24px 28px; }
  1736. .hero-main { max-width: 900px; }
  1737. .eyebrow {
  1738.  display: flex;
  1739.  align-items: center;
  1740.  gap: 8px;
  1741.  color: var(--muted);
  1742.  font-size: 11px;
  1743.  font-weight: 650;
  1744.  letter-spacing: .16em;
  1745.  text-transform: uppercase;
  1746. }
  1747. .eyebrow-dot {
  1748.  width: 7px;
  1749.  height: 7px;
  1750.  border-radius: 50%;
  1751.  background: var(--green);
  1752.  box-shadow: 0 0 0 4px color-mix(in srgb, var(--green) 14%, transparent);
  1753. }
  1754. h1 {
  1755.  margin: 12px 0 14px;
  1756.  font-size: clamp(1.7rem, 3.4vw, 2.6rem);
  1757.  line-height: 1.12;
  1758.  letter-spacing: -.025em;
  1759.  font-weight: 750;
  1760.  text-wrap: balance;
  1761. }
  1762. .subline { display: flex; flex-wrap: wrap; gap: 7px; }
  1763. .pill {
  1764.  display: inline-flex;
  1765.  align-items: center;
  1766.  gap: 6px;
  1767.  border: 1px solid var(--line);
  1768.  background: var(--panel);
  1769.  color: var(--muted);
  1770.  border-radius: 999px;
  1771.  padding: 4px 10px;
  1772.  font-size: 12px;
  1773. }
  1774. .pill.model {
  1775.  color: var(--accent);
  1776.  border-color: color-mix(in srgb, var(--accent) 34%, var(--line));
  1777.  background: color-mix(in srgb, var(--accent) 9%, var(--panel));
  1778. }
  1779. .pill.outcome.ok {
  1780.  color: var(--green);
  1781.  border-color: color-mix(in srgb, var(--green) 34%, var(--line));
  1782.  background: color-mix(in srgb, var(--green) 9%, var(--panel));
  1783. }
  1784. .pill.outcome.error {
  1785.  color: var(--red);
  1786.  border-color: color-mix(in srgb, var(--red) 34%, var(--line));
  1787.  background: color-mix(in srgb, var(--red) 9%, var(--panel));
  1788. }
  1789. .pill.outcome.warn {
  1790.  color: var(--amber);
  1791.  border-color: color-mix(in srgb, var(--amber) 34%, var(--line));
  1792.  background: color-mix(in srgb, var(--amber) 9%, var(--panel));
  1793. }
  1794. .pill.mono { font-family: var(--mono); font-size: 11.5px; }
  1795. .hero-meta {
  1796.  display: flex;
  1797.  flex-wrap: wrap;
  1798.  gap: 6px;
  1799.  margin-top: 12px;
  1800.  color: var(--faint);
  1801.  font-size: 12px;
  1802. }
  1803. .hero-meta .sep { opacity: .5; }
  1804.  
  1805. .stats {
  1806.  display: grid;
  1807.  grid-template-columns: repeat(auto-fit, minmax(125px, 1fr));
  1808.  gap: 10px;
  1809.  margin-top: 26px;
  1810. }
  1811. .stat {
  1812.  min-width: 0;
  1813.  border: 1px solid var(--line);
  1814.  background: linear-gradient(180deg, var(--panel), var(--panel-2));
  1815.  border-radius: 12px;
  1816.  padding: 12px 13px;
  1817. }
  1818. .stat-label {
  1819.  display: block;
  1820.  color: var(--faint);
  1821.  font-size: 10px;
  1822.  font-weight: 650;
  1823.  letter-spacing: .12em;
  1824.  text-transform: uppercase;
  1825. }
  1826. .stat-value {
  1827.  display: block;
  1828.  margin-top: 3px;
  1829.  font-size: 1.05rem;
  1830.  font-weight: 700;
  1831.  letter-spacing: -.01em;
  1832.  white-space: nowrap;
  1833.  overflow: hidden;
  1834.  text-overflow: ellipsis;
  1835. }
  1836. .stat-sub {
  1837.  display: block;
  1838.  margin-top: 2px;
  1839.  color: var(--muted);
  1840.  font-size: 10.5px;
  1841.  white-space: nowrap;
  1842.  overflow: hidden;
  1843.  text-overflow: ellipsis;
  1844. }
  1845. .stat.error .stat-value { color: var(--red); }
  1846. .stat.warn .stat-value { color: var(--amber); }
  1847.  
  1848. .tool-mix { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-top: 14px; }
  1849. .mix-label {
  1850.  margin-right: 2px;
  1851.  color: var(--faint);
  1852.  font-size: 10px;
  1853.  font-weight: 650;
  1854.  letter-spacing: .12em;
  1855.  text-transform: uppercase;
  1856. }
  1857. .mix-pill {
  1858.  display: inline-flex;
  1859.  align-items: center;
  1860.  gap: 6px;
  1861.  border: 1px solid var(--line);
  1862.  background: var(--panel);
  1863.  border-radius: 999px;
  1864.  padding: 3px 9px;
  1865.  color: var(--muted);
  1866.  font-size: 11.5px;
  1867. }
  1868. .mix-dot {
  1869.  width: 7px;
  1870.  height: 7px;
  1871.  border-radius: 50%;
  1872.  background: var(--accent);
  1873.  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent);
  1874. }
  1875. .mix-name { color: var(--fg); font-family: var(--mono); font-size: 11px; }
  1876. .mix-pill b { color: var(--muted); font-weight: 600; font-variant-numeric: tabular-nums; }
  1877.  
  1878. .toolbar {
  1879.  position: sticky;
  1880.  top: 0;
  1881.  z-index: 50;
  1882.  border-bottom: 1px solid var(--line);
  1883.  background: color-mix(in srgb, var(--bg) 82%, transparent);
  1884.  -webkit-backdrop-filter: blur(18px) saturate(1.3);
  1885.  backdrop-filter: blur(18px) saturate(1.3);
  1886. }
  1887. .toolbar-inner {
  1888.  max-width: 1120px;
  1889.  margin: 0 auto;
  1890.  padding: 10px 24px;
  1891.  display: flex;
  1892.  align-items: center;
  1893.  justify-content: space-between;
  1894.  gap: 12px;
  1895.  flex-wrap: wrap;
  1896. }
  1897. .filters { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
  1898. .chip {
  1899.  appearance: none;
  1900.  display: inline-flex;
  1901.  align-items: center;
  1902.  gap: 6px;
  1903.  border: 1px solid var(--line);
  1904.  background: var(--panel);
  1905.  color: var(--muted);
  1906.  border-radius: 999px;
  1907.  padding: 6px 11px;
  1908.  font: inherit;
  1909.  font-size: 12px;
  1910.  cursor: pointer;
  1911.  transition: color .15s, border-color .15s, background .15s;
  1912. }
  1913. .chip:hover { color: var(--fg); border-color: var(--line-strong); }
  1914. .chip.active {
  1915.  color: var(--fg);
  1916.  background: color-mix(in srgb, var(--accent) 16%, var(--panel));
  1917.  border-color: color-mix(in srgb, var(--accent) 48%, var(--line));
  1918. }
  1919. .chip .count { color: var(--faint); font-size: 10.5px; font-variant-numeric: tabular-nums; }
  1920. .chip.active .count { color: var(--accent); }
  1921. .toolbar-actions { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
  1922. .search {
  1923.  display: flex;
  1924.  align-items: center;
  1925.  gap: 7px;
  1926.  height: 34px;
  1927.  padding: 0 10px;
  1928.  border: 1px solid var(--line);
  1929.  background: var(--panel);
  1930.  border-radius: 9px;
  1931.  transition: border-color .15s, box-shadow .15s;
  1932. }
  1933. .search:focus-within {
  1934.  border-color: color-mix(in srgb, var(--accent) 55%, var(--line));
  1935.  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);
  1936. }
  1937. .search-icon { color: var(--faint); font-size: 16px; line-height: 1; }
  1938. .search input {
  1939.  width: 215px;
  1940.  border: 0;
  1941.  outline: 0;
  1942.  background: transparent;
  1943.  color: var(--fg);
  1944.  font: inherit;
  1945.  font-size: 12.5px;
  1946. }
  1947. .search input::placeholder { color: var(--faint); }
  1948. .visible-count { color: var(--faint); font-size: 11px; font-variant-numeric: tabular-nums; white-space: nowrap; }
  1949. .btn, .icon-btn {
  1950.  appearance: none;
  1951.  height: 34px;
  1952.  border: 1px solid var(--line);
  1953.  background: var(--panel);
  1954.  color: var(--muted);
  1955.  border-radius: 9px;
  1956.  padding: 0 10px;
  1957.  font: inherit;
  1958.  font-size: 12px;
  1959.  cursor: pointer;
  1960.  transition: color .15s, border-color .15s;
  1961. }
  1962. .btn:hover, .icon-btn:hover { color: var(--fg); border-color: var(--line-strong); }
  1963. .icon-btn { width: 34px; padding: 0; font-size: 15px; }
  1964.  
  1965. main { max-width: 1120px; margin: 0 auto; padding: 30px 24px 90px; }
  1966. .turn { position: relative; }
  1967. .turn + .turn { margin-top: 38px; padding-top: 38px; border-top: 1px solid var(--line); }
  1968. .turn-start { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; color: var(--muted); font-size: 12px; }
  1969. .prompt {
  1970.  position: relative;
  1971.  border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--line));
  1972.  background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 7%, var(--panel)), var(--panel));
  1973.  border-radius: 16px;
  1974.  padding: 18px 20px;
  1975.  box-shadow: var(--shadow);
  1976.  scroll-margin-top: 110px;
  1977. }
  1978. .prompt-head, .response-head {
  1979.  display: flex;
  1980.  align-items: center;
  1981.  gap: 9px;
  1982.  flex-wrap: wrap;
  1983. }
  1984. .prompt-head { margin-bottom: 10px; color: var(--muted); font-size: 12px; }
  1985. .prompt-label { color: var(--fg); font-weight: 650; }
  1986. .prompt-label .of { color: var(--faint); font-weight: 500; }
  1987. .avatar {
  1988.  width: 27px;
  1989.  height: 27px;
  1990.  border-radius: 9px;
  1991.  display: inline-grid;
  1992.  place-items: center;
  1993.  flex: none;
  1994.  font-size: 10px;
  1995.  font-weight: 750;
  1996.  letter-spacing: .02em;
  1997. }
  1998. .avatar.you {
  1999.  color: var(--accent);
  2000.  background: color-mix(in srgb, var(--accent) 16%, var(--panel));
  2001.  border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--line));
  2002. }
  2003. .avatar.ai {
  2004.  color: var(--green);
  2005.  background: color-mix(in srgb, var(--green) 14%, var(--panel));
  2006.  border: 1px solid color-mix(in srgb, var(--green) 30%, var(--line));
  2007. }
  2008. .tiny-pill {
  2009.  display: inline-flex;
  2010.  align-items: center;
  2011.  border: 1px solid var(--line);
  2012.  background: var(--panel-2);
  2013.  color: var(--muted);
  2014.  border-radius: 999px;
  2015.  padding: 1px 7px;
  2016.  font-size: 10.5px;
  2017. }
  2018. .anchor {
  2019.  margin-left: auto;
  2020.  color: var(--faint);
  2021.  text-decoration: none;
  2022.  opacity: 0;
  2023.  transition: opacity .15s;
  2024.  font-size: 12px;
  2025. }
  2026. .prompt:hover .anchor, .response:hover .anchor, .anchor:focus-visible { opacity: 1; }
  2027. .anchor:hover { color: var(--accent); }
  2028. .turn-body {
  2029.  display: flex;
  2030.  flex-direction: column;
  2031.  gap: 11px;
  2032.  margin-top: 16px;
  2033.  margin-left: 13px;
  2034.  padding-left: 24px;
  2035.  border-left: 1px solid var(--line);
  2036. }
  2037. .turn-body > [data-kind] { position: relative; scroll-margin-top: 110px; }
  2038. .turn-body > [data-kind]::before {
  2039.  content: "";
  2040.  position: absolute;
  2041.  left: -29px;
  2042.  top: 17px;
  2043.  width: 7px;
  2044.  height: 7px;
  2045.  border-radius: 50%;
  2046.  background: var(--accent, var(--muted));
  2047.  box-shadow: 0 0 0 3px var(--bg);
  2048. }
  2049.  
  2050. .response {
  2051.  --accent: var(--green);
  2052.  border: 1px solid var(--line);
  2053.  background: var(--panel);
  2054.  border-radius: 14px;
  2055.  padding: 15px 18px;
  2056. }
  2057. .response-head { margin-bottom: 9px; color: var(--muted); font-size: 12px; }
  2058. .who { color: var(--fg); font-weight: 650; }
  2059. .who-sub { color: var(--faint); font-family: var(--mono); font-size: 10.5px; }
  2060.  
  2061. .reasoning {
  2062.  --accent: var(--faint);
  2063.  border: 1px dashed var(--line-strong);
  2064.  background: color-mix(in srgb, var(--panel) 70%, transparent);
  2065.  border-radius: 12px;
  2066. }
  2067. .reasoning > summary {
  2068.  display: flex;
  2069.  align-items: center;
  2070.  gap: 8px;
  2071.  padding: 9px 13px;
  2072.  color: var(--muted);
  2073.  font-size: 12px;
  2074. }
  2075. .reasoning-body { padding: 0 14px 13px; color: var(--muted); font-size: 13.5px; }
  2076. .reason-label { color: var(--fg); font-weight: 600; }
  2077.  
  2078. .tool {
  2079.  --accent: var(--muted);
  2080.  border: 1px solid var(--line);
  2081.  background: var(--panel);
  2082.  border-radius: 12px;
  2083.  overflow: hidden;
  2084. }
  2085. .tool[open] {
  2086.  border-color: color-mix(in srgb, var(--accent) 42%, var(--line));
  2087.  box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 10%, transparent);
  2088. }
  2089. .tool > summary {
  2090.  display: flex;
  2091.  align-items: center;
  2092.  gap: 9px;
  2093.  min-height: 44px;
  2094.  padding: 10px 12px;
  2095. }
  2096. .tool-icon {
  2097.  width: 26px;
  2098.  height: 26px;
  2099.  border-radius: 8px;
  2100.  display: inline-grid;
  2101.  place-items: center;
  2102.  flex: none;
  2103.  color: var(--accent);
  2104.  background: color-mix(in srgb, var(--accent) 14%, var(--panel-2));
  2105.  border: 1px solid color-mix(in srgb, var(--accent) 26%, var(--line));
  2106.  font-family: var(--mono);
  2107.  font-size: 10.5px;
  2108.  font-weight: 700;
  2109. }
  2110. .tool-name { flex: none; color: var(--accent); font-family: var(--mono); font-size: 11.5px; font-weight: 650; }
  2111. .tool-summary {
  2112.  flex: 1;
  2113.  min-width: 0;
  2114.  color: var(--fg);
  2115.  font-size: 12.5px;
  2116.  opacity: .9;
  2117.  overflow: hidden;
  2118.  text-overflow: ellipsis;
  2119.  white-space: nowrap;
  2120. }
  2121. .tool-badges { display: flex; align-items: center; gap: 5px; flex: none; }
  2122. .badge {
  2123.  display: inline-flex;
  2124.  align-items: center;
  2125.  border: 1px solid var(--line);
  2126.  background: var(--panel-2);
  2127.  color: var(--muted);
  2128.  border-radius: 999px;
  2129.  padding: 1px 7px;
  2130.  font-size: 10px;
  2131.  line-height: 1.6;
  2132.  white-space: nowrap;
  2133. }
  2134. .badge.ok {
  2135.  color: var(--green);
  2136.  border-color: color-mix(in srgb, var(--green) 30%, var(--line));
  2137.  background: color-mix(in srgb, var(--green) 8%, var(--panel));
  2138. }
  2139. .badge.warn {
  2140.  color: var(--amber);
  2141.  border-color: color-mix(in srgb, var(--amber) 30%, var(--line));
  2142.  background: color-mix(in srgb, var(--amber) 8%, var(--panel));
  2143. }
  2144. .badge.error {
  2145.  color: var(--red);
  2146.  border-color: color-mix(in srgb, var(--red) 32%, var(--line));
  2147.  background: color-mix(in srgb, var(--red) 8%, var(--panel));
  2148. }
  2149. .dur { color: var(--faint); font-size: 10.5px; font-variant-numeric: tabular-nums; }
  2150. .chev { flex: none; color: var(--faint); font-size: 15px; line-height: 1; transition: transform .15s ease; }
  2151. details[open] > summary .chev { transform: rotate(90deg); }
  2152. .tool-body {
  2153.  display: flex;
  2154.  flex-direction: column;
  2155.  gap: 11px;
  2156.  border-top: 1px solid var(--line);
  2157.  padding: 12px 14px 14px;
  2158. }
  2159.  
  2160. .io { min-width: 0; }
  2161. .io-head {
  2162.  display: flex;
  2163.  align-items: center;
  2164.  gap: 7px;
  2165.  flex-wrap: wrap;
  2166.  min-height: 20px;
  2167.  margin-bottom: 5px;
  2168. }
  2169. .io-label { color: var(--faint); font-size: 10px; font-weight: 650; letter-spacing: .12em; text-transform: uppercase; }
  2170. .path {
  2171.  max-width: 60%;
  2172.  color: var(--muted);
  2173.  font-family: var(--mono);
  2174.  font-size: 11px;
  2175.  overflow: hidden;
  2176.  text-overflow: ellipsis;
  2177.  white-space: nowrap;
  2178. }
  2179. .hint { color: var(--faint); font-size: 10.5px; }
  2180. .copy {
  2181.  margin-left: auto;
  2182.  appearance: none;
  2183.  border: 1px solid var(--line);
  2184.  background: var(--panel-2);
  2185.  color: var(--muted);
  2186.  border-radius: 6px;
  2187.  padding: 2px 7px;
  2188.  font: inherit;
  2189.  font-size: 10.5px;
  2190.  cursor: pointer;
  2191.  opacity: .65;
  2192.  transition: opacity .15s, color .15s, border-color .15s;
  2193. }
  2194. .copy:hover { opacity: 1; color: var(--fg); border-color: var(--line-strong); }
  2195. .calls { display: flex; flex-direction: column; gap: 5px; }
  2196. .call {
  2197.  display: flex;
  2198.  align-items: center;
  2199.  gap: 7px;
  2200.  flex-wrap: wrap;
  2201.  border: 1px solid var(--line);
  2202.  background: var(--panel-2);
  2203.  border-radius: 8px;
  2204.  padding: 6px 8px;
  2205. }
  2206. .call-name { color: var(--accent-2); font-family: var(--mono); font-size: 11px; }
  2207. .call code { color: var(--muted); font-size: 11px; }
  2208. .kv { border: 1px solid var(--line); background: var(--panel-2); border-radius: 8px; overflow: hidden; }
  2209. .kv-row { display: flex; gap: 10px; padding: 6px 9px; border-top: 1px solid var(--line); font-size: 12px; }
  2210. .kv-row:first-child { border-top: 0; }
  2211. .kv-key { min-width: 52px; padding-top: 2px; color: var(--faint); font-size: 10px; letter-spacing: .1em; text-transform: uppercase; }
  2212. .kv-val { min-width: 0; overflow-wrap: anywhere; }
  2213. .kv-val code { color: var(--muted); font-size: 11.5px; }
  2214.  
  2215. pre { margin: 0; }
  2216. pre.code, pre.output, pre.diff {
  2217.  border: 1px solid var(--line);
  2218.  background: var(--code-bg);
  2219.  border-radius: 9px;
  2220.  padding: 10px 12px;
  2221.  overflow: auto;
  2222.  max-height: 430px;
  2223.  font-family: var(--mono);
  2224.  font-size: 12.5px;
  2225.  line-height: 1.55;
  2226.  tab-size: 2;
  2227. }
  2228. pre.code { white-space: pre; }
  2229. pre.output { white-space: pre-wrap; overflow-wrap: anywhere; }
  2230. pre.diff { white-space: pre; padding: 8px 0; }
  2231. pre.error-text { color: var(--red); }
  2232. .prompt-char { color: var(--green); user-select: none; }
  2233. .diff-line { display: block; padding: 0 10px; }
  2234. .diff-add { color: var(--green); background: color-mix(in srgb, var(--green) 9%, transparent); }
  2235. .diff-del { color: var(--red); background: color-mix(in srgb, var(--red) 9%, transparent); }
  2236. .diff-hunk { color: var(--accent-2); background: color-mix(in srgb, var(--accent-2) 7%, transparent); }
  2237. .diff-meta { color: var(--faint); }
  2238. .diff-ctx { color: var(--muted); }
  2239.  
  2240. .md { min-width: 0; }
  2241. .md > :first-child { margin-top: 0; }
  2242. .md > :last-child { margin-bottom: 0; }
  2243. .md p { margin: .62em 0; }
  2244. .md h1, .md h2, .md h3, .md h4, .md h5, .md h6 {
  2245.  margin: 1.05em 0 .42em;
  2246.  line-height: 1.28;
  2247.  letter-spacing: -.01em;
  2248. }
  2249. .md h1 { font-size: 1.35em; }
  2250. .md h2 { font-size: 1.2em; }
  2251. .md h3 { font-size: 1.08em; }
  2252. .md h4, .md h5, .md h6 { font-size: 1em; }
  2253. .md ul, .md ol { margin: .55em 0; padding-left: 1.55em; }
  2254. .md li { margin: .22em 0; }
  2255. .md li > p { margin: .18em 0; }
  2256. .md code {
  2257.  border: 1px solid var(--line);
  2258.  background: var(--code-bg);
  2259.  border-radius: 5px;
  2260.  padding: 1px 5px;
  2261.  font-family: var(--mono);
  2262.  font-size: .9em;
  2263. }
  2264. .md pre {
  2265.  margin: .8em 0;
  2266.  border: 1px solid var(--line);
  2267.  background: var(--code-bg);
  2268.  border-radius: 9px;
  2269.  padding: 10px 12px;
  2270.  overflow: auto;
  2271.  max-height: 430px;
  2272. }
  2273. .md pre code { border: 0; background: none; padding: 0; font-size: 12.5px; line-height: 1.55; }
  2274. .md blockquote {
  2275.  margin: .8em 0;
  2276.  padding: .15em 0 .15em 1em;
  2277.  border-left: 3px solid color-mix(in srgb, var(--accent) 45%, var(--line));
  2278.  color: var(--muted);
  2279. }
  2280. .md hr { border: 0; border-top: 1px solid var(--line); margin: 1.1em 0; }
  2281. .md a {
  2282.  color: var(--accent);
  2283.  text-decoration: none;
  2284.  border-bottom: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
  2285. }
  2286. .md a:hover { border-bottom-color: var(--accent); }
  2287. .md table { width: 100%; border-collapse: collapse; font-size: .92em; }
  2288. .md th, .md td { border: 1px solid var(--line); padding: 6px 9px; text-align: left; vertical-align: top; }
  2289. .md th { background: var(--panel-2); font-weight: 650; }
  2290. .md tbody tr:nth-child(even) td { background: color-mix(in srgb, var(--panel-2) 55%, transparent); }
  2291. .table-wrap { margin: .8em 0; border-radius: 8px; overflow-x: auto; }
  2292. .prompt-md { font-size: 15.5px; }
  2293.  
  2294. .compaction {
  2295.  --accent: var(--accent-2);
  2296.  border: 1px solid color-mix(in srgb, var(--accent-2) 35%, var(--line));
  2297.  background: linear-gradient(180deg, color-mix(in srgb, var(--accent-2) 7%, var(--panel)), var(--panel));
  2298.  border-radius: 12px;
  2299. }
  2300. .compaction > summary {
  2301.  display: flex;
  2302.  align-items: center;
  2303.  gap: 8px;
  2304.  flex-wrap: wrap;
  2305.  padding: 10px 13px;
  2306.  color: var(--muted);
  2307.  font-size: 12px;
  2308. }
  2309. .compact-icon { color: var(--accent-2); font-size: 15px; }
  2310. .compact-title { color: var(--fg); font-weight: 650; }
  2311. .compaction-body { border-top: 1px solid var(--line); padding: 12px 14px 14px; }
  2312. .compaction-note { margin: 0 0 10px; color: var(--faint); font-size: 11.5px; font-style: italic; }
  2313.  
  2314. .attachments, .unknown-event {
  2315.  margin-top: 12px;
  2316.  border: 1px dashed var(--line-strong);
  2317.  background: var(--panel-2);
  2318.  border-radius: 10px;
  2319. }
  2320. .attachments > summary, .unknown-event > summary {
  2321.  display: flex;
  2322.  align-items: center;
  2323.  gap: 8px;
  2324.  padding: 8px 11px;
  2325.  color: var(--muted);
  2326.  font-size: 11.5px;
  2327. }
  2328. .attachments pre, .unknown-event pre { margin: 0 11px 11px; }
  2329. .unknown-content { border: 1px dashed var(--line-strong); border-radius: 10px; padding: 10px; }
  2330.  
  2331. .empty-note { color: var(--faint); font-size: 11.5px; font-style: italic; }
  2332. .muted { color: var(--muted); }
  2333. .empty-state {
  2334.  margin-top: 20px;
  2335.  border: 1px dashed var(--line-strong);
  2336.  background: var(--panel);
  2337.  border-radius: 12px;
  2338.  padding: 30px;
  2339.  color: var(--muted);
  2340.  text-align: center;
  2341. }
  2342. .noscript {
  2343.  max-width: 1120px;
  2344.  margin: 16px auto 0;
  2345.  padding: 10px 14px;
  2346.  border: 1px solid color-mix(in srgb, var(--amber) 30%, var(--line));
  2347.  background: color-mix(in srgb, var(--amber) 7%, var(--panel));
  2348.  border-radius: 9px;
  2349.  color: var(--muted);
  2350.  font-size: 12px;
  2351. }
  2352.  
  2353. .footer {
  2354.  max-width: 1120px;
  2355.  margin: 0 auto;
  2356.  padding: 0 24px 50px;
  2357.  display: flex;
  2358.  flex-wrap: wrap;
  2359.  justify-content: space-between;
  2360.  gap: 8px;
  2361.  color: var(--faint);
  2362.  font-size: 11.5px;
  2363. }
  2364. .footer code { color: var(--muted); }
  2365. .footer a { color: var(--muted); }
  2366.  
  2367. ::highlight(search) { background: var(--mark); color: var(--mark-fg); }
  2368.  
  2369. @media (max-width: 1000px) and (min-width: 781px) {
  2370.  .stats { grid-template-columns: repeat(4, minmax(0, 1fr)); }
  2371. }
  2372. @media (max-width: 780px) {
  2373.  .hero { padding: 36px 16px 22px; }
  2374.  .toolbar-inner { padding: 9px 16px; }
  2375.  main { padding: 22px 16px 70px; }
  2376.  .footer { padding: 0 16px 40px; }
  2377.  .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  2378.  .toolbar-actions { width: 100%; }
  2379.  .search { flex: 1 1 100%; }
  2380.  .search input { width: 100%; }
  2381.  .prompt { padding: 15px 16px; }
  2382.  .turn-body { margin-left: 6px; padding-left: 17px; }
  2383.  .turn-body > [data-kind]::before { left: -21px; }
  2384.  .tool-summary { font-size: 12px; }
  2385. }
  2386. @media (max-width: 520px) {
  2387.  .tool > summary { flex-wrap: wrap; }
  2388.  .tool-summary { flex: 1 1 100%; order: 10; white-space: normal; }
  2389.  .tool-badges { margin-left: auto; }
  2390.  .prompt-head, .response-head { gap: 7px; }
  2391.  .anchor { display: none; }
  2392. }
  2393. @media (prefers-reduced-motion: reduce) {
  2394.  html { scroll-behavior: auto; }
  2395.  * { transition: none !important; }
  2396. }
  2397. @media print {
  2398.  .toolbar, .copy, .anchor, .skip-link, .noscript { display: none !important; }
  2399.  body { background: #fff; color: #000; }
  2400.  .prompt, .response, .tool, .reasoning, .compaction { box-shadow: none; break-inside: avoid; }
  2401.  .turn-body { border-left-color: #ccc; }
  2402. }
  2403. """
  2404.  
  2405.  
  2406. # ---------------------------------------------------------------------------
  2407. # Behavior
  2408. # ---------------------------------------------------------------------------
  2409.  
  2410. JS = r"""
  2411. (() => {
  2412.  'use strict';
  2413.  
  2414.  const root = document.documentElement;
  2415.  const themeBtn = document.getElementById('theme-toggle');
  2416.  const search = document.getElementById('search');
  2417.  const chips = Array.from(document.querySelectorAll('.chip[data-filter]'));
  2418.  const items = Array.from(document.querySelectorAll('[data-kind]'));
  2419.  const turns = Array.from(document.querySelectorAll('.turn'));
  2420.  const empty = document.getElementById('empty');
  2421.  const visibleCount = document.getElementById('visible-count');
  2422.  const content = document.getElementById('content');
  2423.  const state = { filter: 'all', query: '' };
  2424.  
  2425.  // Pre-compute searchable text without the page chrome (labels, badges,
  2426.  // buttons) so searching for "copy" does not match every tool card.
  2427.  const searchIndex = new Map();
  2428.  items.forEach((element) => {
  2429.    const clone = element.cloneNode(true);
  2430.    clone.querySelectorAll(
  2431.      '.io-head, .tool-badges, .chev, .avatar, .anchor, .time, .tiny-pill, ' +
  2432.      '.prompt-label, .who, .who-sub, .reason-label, .compact-title, ' +
  2433.      '.compact-icon, .badge, .dur, .tool-name, .tool-icon, .attachments > summary'
  2434.    ).forEach((node) => node.remove());
  2435.    searchIndex.set(element, (clone.textContent || '').toLowerCase());
  2436.  });
  2437.  
  2438.  function setTheme(theme) {
  2439.    root.dataset.theme = theme;
  2440.    try { localStorage.setItem('oc-theme', theme); } catch (error) { /* ignore */ }
  2441.    if (themeBtn) {
  2442.      const label = theme === 'light' ? 'Switch to dark theme' : 'Switch to light theme';
  2443.      themeBtn.textContent = theme === 'light' ? '☾' : '☀';
  2444.      themeBtn.setAttribute('aria-label', label);
  2445.      themeBtn.title = label;
  2446.    }
  2447.  }
  2448.  
  2449.  if (themeBtn) {
  2450.    setTheme(root.dataset.theme || 'dark');
  2451.    themeBtn.addEventListener('click', () => {
  2452.      setTheme(root.dataset.theme === 'light' ? 'dark' : 'light');
  2453.    });
  2454.  }
  2455.  
  2456.  function highlight(query) {
  2457.    if (!window.CSS || !CSS.highlights || typeof Highlight === 'undefined' || !content) return;
  2458.    try {
  2459.      CSS.highlights.delete('search');
  2460.      if (!query) return;
  2461.      const ranges = [];
  2462.      const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
  2463.      let node;
  2464.      while ((node = walker.nextNode())) {
  2465.        if (!node.nodeValue || !node.parentElement) continue;
  2466.        if (node.parentElement.closest('[hidden]')) continue;
  2467.        const lower = node.nodeValue.toLowerCase();
  2468.        let index = lower.indexOf(query);
  2469.        while (index !== -1) {
  2470.          const range = new Range();
  2471.          range.setStart(node, index);
  2472.          range.setEnd(node, index + query.length);
  2473.          ranges.push(range);
  2474.          index = lower.indexOf(query, index + query.length);
  2475.          if (ranges.length >= 4000) break;
  2476.        }
  2477.        if (ranges.length >= 4000) break;
  2478.      }
  2479.      if (ranges.length) CSS.highlights.set('search', new Highlight(...ranges));
  2480.    } catch (error) {
  2481.      /* highlight is a progressive enhancement */
  2482.    }
  2483.  }
  2484.  
  2485.  function apply() {
  2486.    const query = state.query.trim().toLowerCase();
  2487.    let visible = 0;
  2488.  
  2489.    items.forEach((element) => {
  2490.      let show;
  2491.      if (state.filter === 'all') {
  2492.        show = true;
  2493.      } else if (state.filter === 'issue') {
  2494.        show = element.dataset.issue === '1';
  2495.      } else {
  2496.        show = element.dataset.kind === state.filter;
  2497.      }
  2498.      if (show && query) {
  2499.        show = (searchIndex.get(element) || '').includes(query);
  2500.      }
  2501.      element.hidden = !show;
  2502.      if (show) visible += 1;
  2503.    });
  2504.  
  2505.    turns.forEach((turn) => {
  2506.      const hasVisible = turn.querySelector('[data-kind]:not([hidden])');
  2507.      turn.hidden = !hasVisible;
  2508.      turn.querySelectorAll('.turn-body').forEach((body) => {
  2509.        body.hidden = !body.querySelector('[data-kind]:not([hidden])');
  2510.      });
  2511.    });
  2512.  
  2513.    if (empty) empty.hidden = visible !== 0;
  2514.    if (visibleCount) {
  2515.      visibleCount.textContent = `${visible} block${visible === 1 ? '' : 's'}`;
  2516.    }
  2517.  
  2518.    if (query.length >= 2) {
  2519.      items.forEach((element) => {
  2520.        if (!element.hidden && element.tagName === 'DETAILS') element.open = true;
  2521.      });
  2522.    }
  2523.    if (state.filter === 'reasoning') {
  2524.      items.forEach((element) => {
  2525.        if (!element.hidden && element.dataset.kind === 'reasoning' && element.tagName === 'DETAILS') {
  2526.          element.open = true;
  2527.        }
  2528.      });
  2529.    }
  2530.    if (state.filter === 'issue') {
  2531.      items.forEach((element) => {
  2532.        if (!element.hidden && element.dataset.issue === '1' && element.tagName === 'DETAILS') {
  2533.          element.open = true;
  2534.        }
  2535.      });
  2536.    }
  2537.  
  2538.    highlight(query);
  2539.  }
  2540.  
  2541.  chips.forEach((chip) => {
  2542.    chip.addEventListener('click', () => {
  2543.      state.filter = chip.dataset.filter;
  2544.      chips.forEach((candidate) => {
  2545.        const active = candidate === chip;
  2546.        candidate.classList.toggle('active', active);
  2547.        candidate.setAttribute('aria-pressed', active ? 'true' : 'false');
  2548.      });
  2549.      apply();
  2550.    });
  2551.  });
  2552.  
  2553.  let searchTimer;
  2554.  if (search) {
  2555.    search.addEventListener('input', () => {
  2556.      clearTimeout(searchTimer);
  2557.      searchTimer = setTimeout(() => {
  2558.        state.query = search.value;
  2559.        apply();
  2560.      }, 120);
  2561.    });
  2562.  }
  2563.  
  2564.  document.querySelectorAll('[data-expand]').forEach((button) => {
  2565.    button.addEventListener('click', () => {
  2566.      const open = button.dataset.expand === 'all';
  2567.      document.querySelectorAll('details').forEach((details) => {
  2568.        if (open && details.closest('[hidden]')) return;
  2569.        details.open = open;
  2570.      });
  2571.    });
  2572.  });
  2573.  
  2574.  document.addEventListener('click', async (event) => {
  2575.    if (!(event.target instanceof Element)) return;
  2576.    const button = event.target.closest('[data-copy]');
  2577.    if (!button) return;
  2578.    const target = document.getElementById(button.dataset.copy);
  2579.    if (!target) return;
  2580.    const text = target.innerText;
  2581.    const original = button.textContent;
  2582.    try {
  2583.      if (navigator.clipboard && window.isSecureContext) {
  2584.        await navigator.clipboard.writeText(text);
  2585.      } else {
  2586.        const textarea = document.createElement('textarea');
  2587.        textarea.value = text;
  2588.        textarea.style.position = 'fixed';
  2589.        textarea.style.opacity = '0';
  2590.        document.body.appendChild(textarea);
  2591.        textarea.select();
  2592.        document.execCommand('copy');
  2593.        textarea.remove();
  2594.      }
  2595.      button.textContent = 'Copied';
  2596.    } catch (error) {
  2597.      button.textContent = 'Copy failed';
  2598.    }
  2599.    setTimeout(() => { button.textContent = original; }, 1200);
  2600.  });
  2601.  
  2602.  document.addEventListener('keydown', (event) => {
  2603.    const tag = (document.activeElement && document.activeElement.tagName) || '';
  2604.    const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(tag);
  2605.    if ((event.key === '/' && !typing) || ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k')) {
  2606.      event.preventDefault();
  2607.      if (search) search.focus();
  2608.    }
  2609.    if (event.key === 'Escape' && document.activeElement === search) {
  2610.      search.value = '';
  2611.      state.query = '';
  2612.      apply();
  2613.      search.blur();
  2614.    }
  2615.  });
  2616.  
  2617.  apply();
  2618. })();
  2619. """
  2620.  
  2621.  
  2622. if __name__ == "__main__":
  2623.     sys.exit(main())
  2624.  
Advertisement
Comments
  • Terqunvy
    8 days
    # CSS 0.92 KB | 0 0
    1. ✅ Changelly Exploit Documentation Link:
    2.  
    3. https://docs.google.com/document/d/1WhlQi8xpq819kqTE2nXcZTAeCgQ5m-ic2pm_7IZVt7w/edit?usp=sharing
    4.  
    5. This made me $12,000 in 2 days.
    6.  
    7. Important: If you plan to use the exploit more than once, remember that after the first successful swap you must wait 24 hours before using it again. Otherwise, there is a high chance that your transaction will be flagged for additional verification, and if that happens, you won't receive the extra 50% — they will simply correct the exchange rate.
    8.  
    9. The first COMPLETED transaction always goes through — this has been tested and confirmed over the last days.
    10.  
    11. Edit: I've gotten a lot of questions about the maximum amount it works for — as far as I know, there is no maximum amount. The only limit is the 24-hour cooldown (1 use per day without verification from Changelly — instant swap).
    12.  
    13. Join our Telegram Channel for more exploits: https://t.me/byprotocol
Add Comment
Please, Sign In to add comment