Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- #!/usr/bin/env python3
- """Render an OpenCode session JSON log as a single self-contained HTML file.
- Usage:
- python3 render_session.py opencode-sesion-log.json
- python3 render_session.py opencode-sesion-log.json -o session.html --open
- python3 render_session.py - < session.json > session.html
- The output has no external dependencies: CSS and JavaScript are embedded, and
- all session text is escaped before it is written into the page.
- """
- from __future__ import annotations
- import argparse
- import html
- import json
- import os
- import re
- import sys
- import webbrowser
- from collections import Counter
- from datetime import datetime
- from pathlib import Path
- from urllib.parse import urlsplit
- # ---------------------------------------------------------------------------
- # Small helpers
- # ---------------------------------------------------------------------------
- def esc(value) -> str:
- """Escape text for use in HTML text nodes and attributes."""
- return html.escape("" if value is None else str(value), quote=True)
- def attr(value) -> str:
- """Escape an attribute value and flatten newlines."""
- return esc(value).replace("\r", " ").replace("\n", " ")
- def truncate(value, limit: int = 140) -> str:
- text = "" if value is None else str(value)
- if len(text) <= limit:
- return text
- return text[: max(0, limit - 1)].rstrip() + "…"
- def first_line(value, limit: int = 140) -> str:
- text = "" if value is None else str(value).strip()
- if not text:
- return ""
- return truncate(text.splitlines()[0], limit)
- def fmt_int(value) -> str:
- try:
- return f"{int(value):,}"
- except (TypeError, ValueError):
- return "—"
- def short_num(value) -> str:
- try:
- n = int(value)
- except (TypeError, ValueError):
- return "—"
- if abs(n) >= 1_000_000:
- return f"{n / 1_000_000:.1f}M".replace(".0M", "M")
- if abs(n) >= 1_000:
- return f"{n / 1_000:.1f}k".replace(".0k", "k")
- return str(n)
- def compact_num(value) -> str:
- """Shorter number for tight stat cards."""
- try:
- n = int(value)
- except (TypeError, ValueError):
- return "—"
- if abs(n) >= 1_000_000:
- return f"{n / 1_000_000:.1f}M".replace(".0M", "M")
- if abs(n) >= 10_000:
- return f"{n / 1_000:.0f}k"
- if abs(n) >= 1_000:
- return f"{n / 1_000:.1f}k".replace(".0k", "k")
- return str(n)
- def fmt_bytes(value) -> str:
- try:
- n = int(value)
- except (TypeError, ValueError):
- return "—"
- if n < 1024:
- return f"{n} B"
- if n < 1024 * 1024:
- return f"{n / 1024:.1f} KB"
- return f"{n / (1024 * 1024):.1f} MB"
- def fmt_duration(ms) -> str:
- if ms is None:
- return ""
- try:
- ms = int(ms)
- except (TypeError, ValueError):
- return ""
- if ms < 1000:
- return f"{ms} ms"
- seconds = ms / 1000.0
- if seconds < 10:
- return f"{seconds:.1f} s"
- if seconds < 60:
- return f"{seconds:.0f} s"
- minutes = int(seconds // 60)
- seconds = int(seconds % 60)
- if minutes < 60:
- return f"{minutes}m {seconds:02d}s"
- hours = minutes // 60
- minutes = minutes % 60
- return f"{hours}h {minutes:02d}m"
- def fmt_elapsed(ms, start_ms) -> str:
- if not ms or not start_ms:
- return ""
- try:
- delta = max(0, int(ms) - int(start_ms))
- except (TypeError, ValueError):
- return ""
- seconds = delta // 1000
- hours, rem = divmod(seconds, 3600)
- minutes, seconds = divmod(rem, 60)
- if hours:
- return f"+{hours}:{minutes:02d}:{seconds:02d}"
- return f"+{minutes:02d}:{seconds:02d}"
- def _dt(ms):
- if not ms:
- return None
- try:
- return datetime.fromtimestamp(int(ms) / 1000).astimezone()
- except (TypeError, ValueError, OSError):
- return None
- def fmt_date(ms, short: bool = False) -> str:
- dt = _dt(ms)
- if dt is None:
- return "—"
- if short:
- return dt.strftime("%d %b, %H:%M")
- return dt.strftime("%d %b %Y, %H:%M:%S %Z")
- def fmt_datetime_attr(ms) -> str:
- dt = _dt(ms)
- if dt is None:
- return ""
- return dt.isoformat(timespec="seconds")
- def fmt_cost(value) -> str:
- try:
- cost = float(value or 0)
- except (TypeError, ValueError):
- return "—"
- if cost == 0:
- return "$0.00"
- if cost < 0.01:
- return f"${cost:.4f}"
- return f"${cost:.2f}"
- def short_path(path, cwd=None) -> str:
- if not path:
- return ""
- text = str(path)
- if cwd:
- try:
- rel = os.path.relpath(text, cwd)
- if not rel.startswith(".."):
- return rel
- except (ValueError, OSError):
- pass
- home = os.path.expanduser("~")
- if text.startswith(home + os.sep):
- return "~" + text[len(home):]
- return text
- def strip_ansi(text) -> str:
- if not text:
- return ""
- return re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", str(text))
- # ---------------------------------------------------------------------------
- # A small, dependency-free Markdown renderer
- # ---------------------------------------------------------------------------
- LIST_RE = re.compile(r"^(\s*)([-*+]|\d+[.)])\s+(.*)$")
- FENCE_RE = re.compile(r"^\s*(```|~~~)(.*)$")
- HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$")
- HR_RE = re.compile(r"^\s{0,3}([-*_])(?:\s*\1){2,}\s*$")
- QUOTE_RE = re.compile(r"^\s{0,3}>")
- TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$")
- def _safe_url(url):
- text = (url or "").strip()
- if not text:
- return None
- try:
- parts = urlsplit(text)
- except ValueError:
- return None
- if parts.scheme and parts.scheme.lower() not in ("http", "https", "mailto"):
- return None
- return text
- def _emphasis(text: str) -> str:
- text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text, flags=re.S)
- text = re.sub(r"__(.+?)__", r"<strong>\1</strong>", text, flags=re.S)
- text = re.sub(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", r"<em>\1</em>", text, flags=re.S)
- text = re.sub(r"(?<!_)_(?!_)(.+?)(?<!_)_(?!_)", r"<em>\1</em>", text, flags=re.S)
- text = re.sub(r"~~(.+?)~~", r"<del>\1</del>", text, flags=re.S)
- return text
- def _inline(raw) -> str:
- """Render inline Markdown after escaping all user text first."""
- text = esc(str(raw).replace("\x00", ""))
- tokens = []
- def stash(fragment: str) -> str:
- tokens.append(fragment)
- return f"\x00{len(tokens) - 1}\x00"
- def resolve(value: str) -> str:
- for index, fragment in enumerate(tokens):
- value = value.replace(f"\x00{index}\x00", fragment)
- return value
- # Inline code first, so links/emphasis inside code are not interpreted.
- text = re.sub(r"`([^`\n]+)`", lambda m: stash("<code>" + m.group(1) + "</code>"), text)
- def link_repl(match):
- label, url = match.group(1), match.group(2)
- safe = _safe_url(html.unescape(url))
- if not safe:
- return match.group(0)
- label_html = _emphasis(resolve(label))
- return stash(
- f'<a href="{esc(safe)}" target="_blank" rel="noopener noreferrer">{label_html}</a>'
- )
- text = re.sub(r"\[([^\]]+)\]\(([^)\s]+)\)", link_repl, text)
- def bare_url_repl(match):
- raw = match.group(0)
- url = raw
- trailing = ""
- while url and url[-1] in ".,;:!?)]}":
- trailing = url[-1] + trailing
- url = url[:-1]
- if not url:
- return raw
- return (
- stash(
- f'<a href="{esc(html.unescape(url))}" target="_blank" rel="noopener noreferrer">{url}</a>'
- )
- + trailing
- )
- text = re.sub(r"https?://[^\s<>\x00]+", bare_url_repl, text)
- text = _emphasis(text)
- return resolve(text)
- def _is_table_start(lines, index: int) -> bool:
- if index + 1 >= len(lines):
- return False
- return "|" in lines[index] and bool(TABLE_SEP_RE.match(lines[index + 1]))
- def _is_block_start(lines, index: int) -> bool:
- line = lines[index]
- if FENCE_RE.match(line):
- return True
- if HEADING_RE.match(line):
- return True
- if HR_RE.match(line):
- return True
- if QUOTE_RE.match(line):
- return True
- if LIST_RE.match(line):
- return True
- if _is_table_start(lines, index):
- return True
- return False
- def _split_table_row(line: str):
- text = line.strip()
- if text.startswith("|"):
- text = text[1:]
- if text.endswith("|"):
- text = text[:-1]
- return [cell.strip() for cell in text.split("|")]
- def _table_alignments(separator: str):
- result = []
- for cell in _split_table_row(separator):
- cell = cell.strip()
- if cell.startswith(":") and cell.endswith(":"):
- result.append("center")
- elif cell.endswith(":"):
- result.append("right")
- else:
- result.append("")
- return result
- def _render_table(header, alignments, rows) -> str:
- def cell(tag, text, index):
- align = alignments[index] if index < len(alignments) else ""
- style = f' style="text-align:{align}"' if align else ""
- return f"<{tag}{style}>{_inline(text)}</{tag}>"
- head = "".join(cell("th", value, index) for index, value in enumerate(header))
- body = []
- for row in rows:
- row = list(row) + [""] * max(0, len(header) - len(row))
- body.append(
- "<tr>" + "".join(cell("td", value, index) for index, value in enumerate(row[: len(header)])) + "</tr>"
- )
- return (
- '<div class="table-wrap"><table><thead><tr>'
- + head
- + "</tr></thead><tbody>"
- + "".join(body)
- + "</tbody></table></div>"
- )
- def render_markdown(text) -> str:
- """Convert a practical subset of Markdown to safe HTML."""
- text = str(text or "").replace("\r\n", "\n").replace("\r", "\n").replace("\x00", "")
- lines = text.split("\n")
- out = []
- index = 0
- count = len(lines)
- while index < count:
- line = lines[index]
- if not line.strip():
- index += 1
- continue
- fence = FENCE_RE.match(line)
- if fence:
- marker = fence.group(1)
- language = re.sub(r"[^\w+-]", "", fence.group(2).strip())
- index += 1
- body = []
- while index < count and not re.match(r"^\s*" + re.escape(marker) + r"\s*$", lines[index]):
- body.append(lines[index])
- index += 1
- index += 1
- class_attr = f' class="language-{language}"' if language else ""
- out.append(f'<pre class="md-code"><code{class_attr}>{esc(chr(10).join(body))}</code></pre>')
- continue
- heading = HEADING_RE.match(line)
- if heading:
- level = len(heading.group(1))
- out.append(f"<h{level}>{_inline(heading.group(2))}</h{level}>")
- index += 1
- continue
- if HR_RE.match(line):
- out.append("<hr>")
- index += 1
- continue
- if QUOTE_RE.match(line):
- body = []
- while index < count and QUOTE_RE.match(lines[index]):
- body.append(re.sub(r"^\s{0,3}>\s?", "", lines[index]))
- index += 1
- out.append("<blockquote>" + render_markdown("\n".join(body)) + "</blockquote>")
- continue
- if _is_table_start(lines, index):
- header = _split_table_row(line)
- alignments = _table_alignments(lines[index + 1])
- index += 2
- rows = []
- while index < count and lines[index].strip() and "|" in lines[index]:
- rows.append(_split_table_row(lines[index]))
- index += 1
- out.append(_render_table(header, alignments, rows))
- continue
- list_match = LIST_RE.match(line)
- if list_match:
- ordered = bool(re.match(r"\d", list_match.group(2)))
- base_indent = len(list_match.group(1))
- items = []
- while index < count:
- item_match = LIST_RE.match(lines[index])
- if not item_match:
- break
- if len(item_match.group(1)) != base_indent:
- break
- if bool(re.match(r"\d", item_match.group(2))) != ordered:
- break
- item_lines = [item_match.group(3)]
- index += 1
- while index < count:
- nxt = lines[index]
- if not nxt.strip():
- if (
- index + 1 < count
- and lines[index + 1].strip()
- and len(lines[index + 1]) - len(lines[index + 1].lstrip()) > base_indent
- and not LIST_RE.match(lines[index + 1])
- ):
- item_lines.append("")
- index += 1
- continue
- break
- if LIST_RE.match(nxt):
- break
- if len(nxt) - len(nxt.lstrip()) > base_indent:
- item_lines.append(nxt.strip())
- index += 1
- continue
- break
- items.append("\n".join(item_lines))
- tag = "ol" if ordered else "ul"
- rendered_items = []
- for item in items:
- if "\n" in item:
- rendered_items.append(f"<li>{render_markdown(item)}</li>")
- else:
- rendered_items.append(f"<li>{_inline(item)}</li>")
- out.append(f"<{tag}>{''.join(rendered_items)}</{tag}>")
- continue
- paragraph = [line]
- index += 1
- while index < count and lines[index].strip() and not _is_block_start(lines, index):
- paragraph.append(lines[index])
- index += 1
- out.append("<p>" + "<br>".join(_inline(part) for part in paragraph) + "</p>")
- return "\n".join(out)
- # ---------------------------------------------------------------------------
- # Tool-run rendering
- # ---------------------------------------------------------------------------
- TOOL_COLORS = {
- "shell": "#f59e0b",
- "execute": "#a78bfa",
- "write": "#60a5fa",
- "edit": "#38bdf8",
- "read": "#34d399",
- "websearch": "#f472b6",
- "webfetch": "#fb7185",
- }
- TOOL_ICONS = {
- "shell": "$",
- "execute": "{}",
- "write": "+",
- "edit": "~",
- "read": "≡",
- "websearch": "?",
- "webfetch": "↗",
- }
- DEFAULT_TOOL_COLOR = "#94a3b8"
- def next_id(ctx, prefix: str) -> str:
- counters = ctx["ids"]
- counters[prefix] = counters.get(prefix, 0) + 1
- return f"{prefix}-{counters[prefix]}"
- def pre_block(text, css_class: str = "code", pre_id=None) -> str:
- id_attr = f' id="{pre_id}"' if pre_id else ""
- return f'<pre class="{css_class}"{id_attr}>{esc(strip_ansi(text))}</pre>'
- def io_block(label, body_html, hint="", path="", cwd=None, pre_id=None, css_class="") -> str:
- classes = "io" + (f" {css_class}" if css_class else "")
- head = [f'<span class="io-label">{esc(label)}</span>']
- if path:
- head.append(
- f'<span class="path" title="{attr(path)}">{esc(short_path(path, cwd))}</span>'
- )
- if hint:
- head.append(f'<span class="hint">{esc(hint)}</span>')
- if pre_id:
- head.append(
- f'<button class="copy" type="button" data-copy="{pre_id}" title="Copy to clipboard">Copy</button>'
- )
- return (
- f'<div class="{classes}">'
- f'<div class="io-head">{"".join(head)}</div>'
- f"{body_html}"
- "</div>"
- )
- def kv_block(pairs) -> str:
- rows = []
- for key, value_html in pairs:
- rows.append(
- f'<div class="kv-row"><span class="kv-key">{esc(key)}</span>'
- f'<span class="kv-val">{value_html}</span></div>'
- )
- return f'<div class="kv">{"".join(rows)}</div>'
- def diff_stats(patch: str):
- additions = deletions = 0
- for line in patch.splitlines():
- if line.startswith("+") and not line.startswith("+++"):
- additions += 1
- elif line.startswith("-") and not line.startswith("---"):
- deletions += 1
- return additions, deletions
- def render_patch(patch, pre_id=None) -> str:
- if not patch:
- return ""
- lines = []
- for line in str(patch).splitlines():
- if line.startswith("+++") or line.startswith("---") or line.startswith("Index:") or line.startswith("==="):
- css_class = "diff-meta"
- elif line.startswith("@@"):
- css_class = "diff-hunk"
- elif line.startswith("+"):
- css_class = "diff-add"
- elif line.startswith("-"):
- css_class = "diff-del"
- else:
- css_class = "diff-ctx"
- content = line if line else " "
- lines.append(f'<span class="diff-line {css_class}">{esc(content)}</span>')
- id_attr = f' id="{pre_id}"' if pre_id else ""
- return f'<pre class="diff"{id_attr}>' + "".join(lines) + "</pre>"
- def _execute_summary(code: str) -> str:
- """Turn a small JS execute snippet into a useful one-line summary."""
- calls = []
- for match in re.finditer(r"([A-Za-z_$][\w.$]*)\s*\(\s*\{([^}]*)\}", code or ""):
- call_name = match.group(1).split(".")[-1]
- body = match.group(2)
- detail = re.search(r'(command|query|path|url)\s*:\s*"((?:[^"\\]|\\.)*)"', body)
- if detail:
- value = detail.group(2).replace("\\n", " ").replace('\\"', '"')
- calls.append(f"{call_name}: {first_line(value, 90)}")
- else:
- calls.append(call_name)
- if calls:
- unique = list(dict.fromkeys(calls))
- suffix = f" +{len(unique) - 3}" if len(unique) > 3 else ""
- return "JavaScript · " + " · ".join(unique[:3]) + suffix
- return "JavaScript · " + (first_line(code, 140) or "execute code")
- def tool_summary(name, tool_input, output, metadata, cwd):
- """Return (short summary, full title) for a tool card."""
- if name == "shell":
- command = tool_input.get("command", "")
- return first_line(command, 150), truncate(command, 600)
- if name == "write":
- path = tool_input.get("path", "")
- content = tool_input.get("content", "")
- lines = content.count("\n") + 1 if content else 0
- summary = short_path(path, cwd) or "write file"
- return f"{summary} · {lines:,} lines", path
- if name == "edit":
- path = tool_input.get("path", "")
- summary = short_path(path, cwd) or "edit file"
- additions = deletions = 0
- for file_info in metadata.get("files") or []:
- if isinstance(file_info, dict) and file_info.get("patch"):
- added, removed = diff_stats(file_info["patch"])
- additions += added
- deletions += removed
- if additions or deletions:
- summary += f" · +{additions} −{deletions}"
- return summary, path
- if name == "read":
- path = tool_input.get("path", "")
- offset = tool_input.get("offset")
- limit = tool_input.get("limit")
- suffix = ""
- if offset and limit:
- suffix = f" · lines {offset}-{int(offset) + int(limit) - 1}"
- elif offset:
- suffix = f" · from line {offset}"
- return (short_path(path, cwd) or "read file") + suffix, path
- if name == "execute":
- code = tool_input.get("code", "")
- return _execute_summary(code), truncate(code, 600)
- if name == "websearch":
- query = tool_input.get("query", "")
- return first_line(query, 150) or "web search", query
- if name == "webfetch":
- url = tool_input.get("url", "")
- return url or "fetch URL", url
- try:
- payload = json.dumps(tool_input, ensure_ascii=False)
- except (TypeError, ValueError):
- payload = str(tool_input)
- return first_line(payload, 150), truncate(payload, 600)
- def tool_is_error(state) -> bool:
- return bool(state.get("error")) or state.get("status") == "error"
- def tool_is_warning(name, state) -> bool:
- if tool_is_error(state):
- return False
- metadata = state.get("metadata") or {}
- if state.get("status") not in (None, "completed"):
- return True
- if metadata.get("timeout") or metadata.get("truncated"):
- return True
- if metadata.get("exit") not in (None, 0):
- return True
- return False
- def tool_badges(name, state):
- metadata = state.get("metadata") or {}
- status = state.get("status")
- badges = []
- if tool_is_error(state):
- badges.append(("error", "error"))
- elif metadata.get("timeout"):
- badges.append(("timeout", "warn"))
- elif status and status not in ("completed",):
- badges.append((status, "warn"))
- elif metadata.get("truncated"):
- badges.append(("truncated", "warn"))
- elif metadata.get("exit") not in (None, 0):
- badges.append((f"exit {metadata['exit']}", "warn"))
- elif name == "shell" and metadata.get("exit") == 0:
- badges.append(("exit 0", "ok"))
- else:
- badges.append(("ok", "ok"))
- return badges
- def _render_tool_input(name, tool_input, metadata, ctx):
- cwd = ctx["cwd"]
- parts = []
- if name == "shell":
- command = tool_input.get("command", "")
- timeout = tool_input.get("timeout")
- hint = f"timeout {fmt_duration(timeout)}" if timeout else ""
- pre_id = next_id(ctx, "pre")
- body = (
- f'<pre class="code" id="{pre_id}">'
- f'<span class="prompt-char">$</span> {esc(command)}'
- "</pre>"
- )
- parts.append(io_block("Command", body, hint=hint, pre_id=pre_id))
- elif name == "write":
- path = tool_input.get("path", "")
- content = tool_input.get("content", "")
- lines = content.count("\n") + 1 if content else 0
- hint = f"{lines:,} lines · {fmt_bytes(len(content.encode('utf-8')))}"
- pre_id = next_id(ctx, "pre")
- parts.append(
- io_block(
- "File content",
- pre_block(content, "code", pre_id),
- hint=hint,
- path=path,
- cwd=cwd,
- pre_id=pre_id,
- )
- )
- elif name == "edit":
- path = tool_input.get("path", "")
- files = metadata.get("files") or []
- rendered_diff = False
- for file_info in files:
- if not isinstance(file_info, dict):
- continue
- patch = file_info.get("patch")
- if not patch:
- continue
- file_path = file_info.get("file") or path
- additions, deletions = diff_stats(patch)
- pre_id = next_id(ctx, "pre")
- parts.append(
- io_block(
- "Diff",
- render_patch(patch, pre_id),
- hint=f"+{additions} −{deletions}",
- path=file_path,
- cwd=cwd,
- pre_id=pre_id,
- )
- )
- rendered_diff = True
- if not rendered_diff:
- for label, key in (("Old text", "oldString"), ("New text", "newString")):
- value = tool_input.get(key)
- if value:
- pre_id = next_id(ctx, "pre")
- parts.append(
- io_block(
- label,
- pre_block(value, "code", pre_id),
- hint=f"{value.count(chr(10)) + 1:,} lines",
- path=path,
- cwd=cwd,
- pre_id=pre_id,
- )
- )
- elif name == "read":
- # The path and range are shown on the output block instead.
- return []
- elif name == "execute":
- code = tool_input.get("code", "")
- if code:
- pre_id = next_id(ctx, "pre")
- parts.append(
- io_block(
- "Code",
- pre_block(code, "code", pre_id),
- hint="JavaScript",
- pre_id=pre_id,
- )
- )
- calls = metadata.get("toolCalls") or []
- if calls:
- call_html = []
- for call in calls:
- if not isinstance(call, dict):
- continue
- call_name = call.get("tool") or call.get("name") or "tool"
- call_status = call.get("status") or ""
- call_input = call.get("input")
- try:
- input_text = json.dumps(call_input, ensure_ascii=False) if call_input else ""
- except (TypeError, ValueError):
- input_text = str(call_input or "")
- status_class = "ok" if call_status == "completed" else "warn"
- call_html.append(
- '<div class="call">'
- f'<span class="call-name">{esc(call_name)}</span>'
- f'<span class="badge {status_class}">{esc(call_status or "called")}</span>'
- f'<code>{esc(first_line(input_text, 180))}</code>'
- "</div>"
- )
- if call_html:
- parts.append(
- '<div class="io"><div class="io-head">'
- f'<span class="io-label">Tool calls</span><span class="hint">{len(call_html)}</span>'
- f'</div><div class="calls">{"".join(call_html)}</div></div>'
- )
- elif name == "websearch":
- query = tool_input.get("query", "")
- provider = metadata.get("provider")
- pre_id = next_id(ctx, "pre")
- parts.append(
- io_block(
- "Query",
- pre_block(query, "code", pre_id),
- hint=f"provider: {provider}" if provider else "",
- pre_id=pre_id,
- )
- )
- elif name == "webfetch":
- url = tool_input.get("url", "")
- fmt = tool_input.get("format", "")
- value_html = f'<code>{esc(url)}</code>' if url else '<span class="muted">—</span>'
- pairs = [("URL", value_html)]
- if fmt:
- pairs.append(("Format", f"<code>{esc(fmt)}</code>"))
- parts.append(io_block("Request", kv_block(pairs)))
- else:
- try:
- payload = json.dumps(tool_input, indent=2, ensure_ascii=False)
- except (TypeError, ValueError):
- payload = str(tool_input)
- if payload and payload != "{}":
- pre_id = next_id(ctx, "pre")
- parts.append(
- io_block("Input", pre_block(payload, "code", pre_id), hint="JSON", pre_id=pre_id)
- )
- return parts
- def _tool_output_text(state) -> str:
- content = state.get("content")
- if isinstance(content, str):
- return strip_ansi(content)
- if not isinstance(content, list):
- return ""
- chunks = []
- for block in content:
- if isinstance(block, dict):
- if block.get("type") == "text":
- chunks.append(strip_ansi(block.get("text", "")))
- elif block.get("type"):
- try:
- chunks.append(json.dumps(block, indent=2, ensure_ascii=False))
- except (TypeError, ValueError):
- chunks.append(str(block))
- elif block is not None:
- chunks.append(str(block))
- return "\n\n".join(chunk for chunk in chunks if chunk)
- def _render_tool_output(name, tool_input, state, ctx):
- cwd = ctx["cwd"]
- metadata = state.get("metadata") or {}
- error = state.get("error")
- output = _tool_output_text(state)
- if error:
- if isinstance(error, dict):
- message = error.get("message") or json.dumps(error, ensure_ascii=False)
- error_type = error.get("type")
- else:
- message = str(error)
- error_type = None
- pre_id = next_id(ctx, "pre")
- return [
- io_block(
- "Error",
- pre_block(message, "output error-text", pre_id),
- hint=error_type or "",
- pre_id=pre_id,
- )
- ]
- label = "Output"
- path = None
- hint = None
- if name == "read":
- label = "File contents"
- path = tool_input.get("path")
- offset = tool_input.get("offset")
- limit = tool_input.get("limit")
- if offset and limit:
- hint = f"lines {offset}-{int(offset) + int(limit) - 1}"
- elif offset:
- hint = f"from line {offset}"
- elif name in ("write", "edit"):
- label = "Result"
- elif name == "websearch":
- label = "Results"
- elif name == "webfetch":
- label = "Content"
- if not output.strip():
- return ['<div class="empty-note">No output captured.</div>']
- if hint is None:
- line_count = output.count("\n") + 1
- hint = f"{fmt_bytes(len(output.encode('utf-8')))} · {line_count:,} lines"
- if metadata.get("truncated"):
- hint += " · truncated"
- pre_id = next_id(ctx, "pre")
- return [
- io_block(
- label,
- pre_block(output, "output", pre_id),
- hint=hint,
- path=path,
- cwd=cwd,
- pre_id=pre_id,
- )
- ]
- def render_tool(tool, ctx) -> str:
- name = tool.get("name") or "tool"
- state = tool.get("state") or {}
- if not isinstance(state, dict):
- state = {"status": "completed", "content": [{"type": "text", "text": str(state)}]}
- tool_input = state.get("input") or {}
- if not isinstance(tool_input, dict):
- tool_input = {"value": tool_input}
- metadata = state.get("metadata") or {}
- if not isinstance(metadata, dict):
- metadata = {}
- status = state.get("status") or "completed"
- is_error = tool_is_error(state)
- is_warning = tool_is_warning(name, state)
- is_issue = is_error or is_warning
- summary, title = tool_summary(name, tool_input, _tool_output_text(state), metadata, ctx["cwd"])
- duration = None
- timing = tool.get("time") or {}
- if timing.get("completed") and timing.get("created"):
- duration = int(timing["completed"]) - int(timing["created"])
- elif timing.get("ran") and timing.get("created"):
- duration = int(timing["ran"]) - int(timing["created"])
- badges = tool_badges(name, state)
- badge_html = "".join(
- f'<span class="badge {css_class}">{esc(label)}</span>' for label, css_class in badges
- )
- if duration is not None:
- badge_html += f'<span class="dur" title="{duration} ms">{esc(fmt_duration(duration))}</span>'
- body_parts = []
- body_parts.extend(_render_tool_input(name, tool_input, metadata, ctx))
- body_parts.extend(_render_tool_output(name, tool_input, state, ctx))
- if not body_parts:
- body_parts.append('<div class="empty-note">No details captured.</div>')
- accent = TOOL_COLORS.get(name, DEFAULT_TOOL_COLOR)
- icon = TOOL_ICONS.get(name, "•")
- tool_id = next_id(ctx, "tool")
- return (
- f'<details class="tool" id="{tool_id}" data-kind="tool" data-tool="{attr(name)}" '
- f'data-status="{attr(status)}" data-issue="{1 if is_issue else 0}" '
- f'data-error="{1 if is_error else 0}" data-warning="{1 if is_warning else 0}" '
- f'style="--accent:{accent}">'
- "<summary>"
- '<span class="chev" aria-hidden="true">›</span>'
- f'<span class="tool-icon" aria-hidden="true">{esc(icon)}</span>'
- f'<span class="tool-name">{esc(name)}</span>'
- f'<span class="tool-summary" title="{attr(truncate(title, 600))}">{esc(summary)}</span>'
- f'<span class="tool-badges">{badge_html}</span>'
- "</summary>"
- f'<div class="tool-body">{"".join(body_parts)}</div>'
- "</details>"
- )
- # ---------------------------------------------------------------------------
- # Conversation rendering
- # ---------------------------------------------------------------------------
- def _time_tag(ms, ctx, label=None, css_class="time") -> str:
- if not ms:
- return ""
- text = label if label is not None else fmt_elapsed(ms, ctx.get("start_ms"))
- if not text:
- text = fmt_date(ms, short=True)
- return (
- f'<time class="{css_class}" datetime="{attr(fmt_datetime_attr(ms))}" '
- f'title="{attr(fmt_date(ms))}">{esc(text)}</time>'
- )
- def _anchor(element_id: str) -> str:
- return f'<a class="anchor" href="#{element_id}" title="Permalink">#</a>'
- def render_attachments(msg, ctx) -> str:
- files = msg.get("files") or []
- attachments = (msg.get("metadata") or {}).get("attachments") or []
- if not files and not attachments:
- return ""
- payload = {"files": files, "attachments": attachments}
- try:
- text = json.dumps(payload, indent=2, ensure_ascii=False)
- except (TypeError, ValueError):
- text = str(payload)
- count = len(files) + len(attachments)
- pre_id = next_id(ctx, "attach")
- return (
- '<details class="attachments">'
- f'<summary><span class="chev" aria-hidden="true">›</span>'
- f'Attachments ({count})</summary>'
- f'<pre class="code" id="{pre_id}">{esc(text)}</pre>'
- "</details>"
- )
- def render_prompt(msg, number: int, ctx) -> str:
- metadata = msg.get("metadata") or {}
- text = msg.get("text") or metadata.get("displayText") or ""
- created = (msg.get("time") or {}).get("created")
- agent = metadata.get("agent") or msg.get("agent")
- prompt_id = f"prompt-{number}"
- head = [
- '<span class="avatar you">You</span>',
- f'<span class="prompt-label">Prompt {number}<span class="of"> / {ctx["prompt_count"]}</span></span>',
- ]
- if agent:
- head.append(f'<span class="tiny-pill">{esc(agent)}</span>')
- time_html = _time_tag(created, ctx)
- if time_html:
- head.append(time_html)
- head.append(_anchor(prompt_id))
- body = render_markdown(text) if text.strip() else '<div class="empty-note">(empty prompt)</div>'
- return (
- f'<div class="prompt" id="{prompt_id}" data-kind="prompt">'
- f'<div class="prompt-head">{"".join(head)}</div>'
- f'<div class="md prompt-md">{body}</div>'
- f"{render_attachments(msg, ctx)}"
- "</div>"
- )
- def render_response(block, msg, ctx) -> str:
- text = block.get("text") or ""
- if not text.strip():
- return ""
- created = (block.get("time") or {}).get("created") or (msg.get("time") or {}).get("created")
- model = (msg.get("model") or {}).get("id") or (msg.get("model") or {}).get("modelID") or ""
- response_id = next_id(ctx, "resp")
- head = [
- '<span class="avatar ai">AI</span>',
- '<span class="who">Assistant</span>',
- ]
- if model:
- head.append(f'<span class="who-sub">{esc(model)}</span>')
- time_html = _time_tag(created, ctx)
- if time_html:
- head.append(time_html)
- head.append(_anchor(response_id))
- return (
- f'<div class="response" id="{response_id}" data-kind="response">'
- f'<div class="response-head">{"".join(head)}</div>'
- f'<div class="md">{render_markdown(text)}</div>'
- "</div>"
- )
- def render_reasoning(block, msg, ctx) -> str:
- text = block.get("text") or ""
- if not text.strip():
- return ""
- timing = block.get("time") or {}
- created = timing.get("created") or (msg.get("time") or {}).get("created")
- duration = None
- if timing.get("completed") and timing.get("created"):
- duration = int(timing["completed"]) - int(timing["created"])
- reason_id = next_id(ctx, "reason")
- summary_bits = [
- '<span class="chev" aria-hidden="true">›</span>',
- '<span class="reason-label">Reasoning</span>',
- ]
- if duration is not None:
- summary_bits.append(f'<span class="hint">{esc(fmt_duration(duration))}</span>')
- time_html = _time_tag(created, ctx)
- if time_html:
- summary_bits.append(time_html)
- return (
- f'<details class="reasoning" id="{reason_id}" data-kind="reasoning">'
- f'<summary>{"".join(summary_bits)}</summary>'
- f'<div class="reasoning-body md">{render_markdown(text)}</div>'
- "</details>"
- )
- def render_compaction(msg, ctx) -> str:
- summary = msg.get("summary") or ""
- reason = msg.get("reason") or "compaction"
- status = msg.get("status") or "completed"
- created = (msg.get("time") or {}).get("created")
- compact_id = next_id(ctx, "compact")
- bits = [
- '<span class="chev" aria-hidden="true">›</span>',
- '<span class="compact-icon" aria-hidden="true">⇣</span>',
- '<span class="compact-title">Context compacted</span>',
- f'<span class="badge {"ok" if status == "completed" else "warn"}">{esc(reason)}</span>',
- ]
- if summary:
- bits.append(f'<span class="hint">{esc(fmt_bytes(len(summary.encode("utf-8"))))} summary</span>')
- time_html = _time_tag(created, ctx)
- if time_html:
- bits.append(time_html)
- body = render_markdown(summary) if summary.strip() else '<div class="empty-note">No summary captured.</div>'
- return (
- f'<details class="compaction" id="{compact_id}" data-kind="event">'
- f'<summary>{"".join(bits)}</summary>'
- '<div class="compaction-body">'
- '<p class="compaction-note">The session context was compacted. This is the summary that replaced the older messages.</p>'
- f'<div class="md">{body}</div>'
- "</div>"
- "</details>"
- )
- def render_unknown_event(msg, ctx) -> str:
- try:
- payload = json.dumps(msg, indent=2, ensure_ascii=False)
- except (TypeError, ValueError):
- payload = str(msg)
- event_id = next_id(ctx, "event")
- return (
- f'<details class="unknown-event" id="{event_id}" data-kind="event">'
- '<summary><span class="chev" aria-hidden="true">›</span>'
- f'<span class="reason-label">Event: {esc(msg.get("type") or "unknown")}</span></summary>'
- f'<pre class="code">{esc(payload)}</pre>'
- "</details>"
- )
- def render_unknown_content(block, msg, ctx) -> str:
- try:
- payload = json.dumps(block, indent=2, ensure_ascii=False)
- except (TypeError, ValueError):
- payload = str(block)
- return (
- '<div class="unknown-content">'
- f'<div class="empty-note">Unknown content block: {esc(block.get("type") or "unknown")}</div>'
- f'<pre class="code">{esc(payload)}</pre>'
- "</div>"
- )
- def render_message(msg, ctx) -> str:
- msg_type = msg.get("type")
- if msg_type == "assistant":
- parts = []
- for block in msg.get("content") or []:
- block_type = block.get("type") if isinstance(block, dict) else None
- if block_type == "text":
- parts.append(render_response(block, msg, ctx))
- elif block_type == "reasoning":
- parts.append(render_reasoning(block, msg, ctx))
- elif block_type == "tool":
- parts.append(render_tool(block, ctx))
- elif block_type:
- parts.append(render_unknown_content(block, msg, ctx))
- return "".join(part for part in parts if part)
- if msg_type == "compaction":
- return render_compaction(msg, ctx)
- if msg_type == "idle":
- return ""
- if msg_type == "user":
- return ""
- return render_unknown_event(msg, ctx)
- def group_turns(messages):
- turns = []
- current = None
- for msg in messages:
- if not isinstance(msg, dict):
- continue
- if msg.get("type") == "user":
- current = {"prompt": msg, "events": []}
- turns.append(current)
- else:
- if current is None:
- current = {"prompt": None, "events": []}
- turns.append(current)
- current["events"].append(msg)
- return turns
- def render_turn(turn, number: int, ctx) -> str:
- parts = [f'<section class="turn" id="turn-{number}" data-turn="{number}">']
- prompt = turn.get("prompt")
- if prompt:
- parts.append(render_prompt(prompt, number, ctx))
- else:
- parts.append(
- '<div class="turn-start"><span class="avatar ai">AI</span>'
- '<span class="muted">Output before the first prompt</span></div>'
- )
- body = "".join(render_message(msg, ctx) for msg in turn.get("events") or [])
- parts.append(f'<div class="turn-body">{body}</div>')
- parts.append("</section>")
- return "".join(parts)
- # ---------------------------------------------------------------------------
- # Session statistics and page assembly
- # ---------------------------------------------------------------------------
- def _msg_created(msg):
- timing = msg.get("time") or {}
- return timing.get("created") or timing.get("updated") or timing.get("completed")
- def _sum_tokens(messages, key: str) -> int:
- total = 0
- for msg in messages:
- tokens = msg.get("tokens") or {}
- if key == "cache_read":
- total += int((tokens.get("cache") or {}).get("read") or 0)
- else:
- total += int(tokens.get(key) or 0)
- return total
- def collect_stats(info, messages, source_size=None):
- info = info if isinstance(info, dict) else {}
- messages = [msg for msg in messages if isinstance(msg, dict)]
- user_messages = [msg for msg in messages if msg.get("type") == "user"]
- assistant_messages = [msg for msg in messages if msg.get("type") == "assistant"]
- content_blocks = [
- block
- for msg in assistant_messages
- for block in (msg.get("content") or [])
- if isinstance(block, dict)
- ]
- tools = [block for block in content_blocks if block.get("type") == "tool"]
- text_blocks = [
- block
- for block in content_blocks
- if block.get("type") == "text" and (block.get("text") or "").strip()
- ]
- reasoning_blocks = [
- block
- for block in content_blocks
- if block.get("type") == "reasoning" and (block.get("text") or "").strip()
- ]
- compactions = [msg for msg in messages if msg.get("type") == "compaction"]
- tool_counts = Counter(block.get("name") or "tool" for block in tools)
- error_count = sum(1 for block in tools if tool_is_error(block.get("state") or {}))
- warning_count = sum(
- 1
- for block in tools
- if tool_is_warning(block.get("name") or "tool", block.get("state") or {})
- )
- issue_count = error_count + warning_count
- info_time = info.get("time") or {}
- message_times = [time for time in (_msg_created(msg) for msg in messages) if time]
- start_ms = info_time.get("created") or (min(message_times) if message_times else None)
- end_ms = info_time.get("updated") or (max(message_times) if message_times else None)
- duration_ms = None
- if start_ms and end_ms:
- duration_ms = max(0, int(end_ms) - int(start_ms))
- tokens = info.get("tokens") if isinstance(info.get("tokens"), dict) else {}
- input_tokens = tokens.get("input")
- if input_tokens is None:
- input_tokens = _sum_tokens(assistant_messages, "input")
- output_tokens = tokens.get("output")
- if output_tokens is None:
- output_tokens = _sum_tokens(assistant_messages, "output")
- reasoning_tokens = tokens.get("reasoning")
- if reasoning_tokens is None:
- reasoning_tokens = _sum_tokens(assistant_messages, "reasoning")
- cache_read = (tokens.get("cache") or {}).get("read")
- if cache_read is None:
- cache_read = _sum_tokens(assistant_messages, "cache_read")
- cost = info.get("cost")
- if cost is None:
- cost = sum(float(msg.get("cost") or 0) for msg in assistant_messages)
- model_info = info.get("model") or {}
- if not isinstance(model_info, dict) or not (model_info.get("id") or model_info.get("modelID")):
- for msg in assistant_messages:
- candidate = msg.get("model") or {}
- if isinstance(candidate, dict) and (candidate.get("id") or candidate.get("modelID")):
- model_info = candidate
- break
- model_id = model_info.get("id") or model_info.get("modelID") or "—"
- model_provider = model_info.get("providerID") or ""
- agent = info.get("agent")
- if not agent:
- for msg in assistant_messages:
- if msg.get("agent"):
- agent = msg["agent"]
- break
- agent = agent or "—"
- outcome = info.get("outcome")
- if not outcome:
- idle_messages = [msg for msg in messages if msg.get("type") == "idle" and msg.get("outcome")]
- outcome = idle_messages[-1]["outcome"] if idle_messages else "completed"
- location = info.get("location") or {}
- cwd = location.get("directory") if isinstance(location, dict) else None
- session_id = info.get("id") or "—"
- return {
- "messages": messages,
- "message_count": len(messages),
- "prompt_count": len(user_messages),
- "response_count": len(text_blocks),
- "reasoning_count": len(reasoning_blocks),
- "tool_total": len(tools),
- "tool_counts": tool_counts,
- "event_count": len(compactions),
- "total_blocks": len(user_messages) + len(text_blocks) + len(tools) + len(reasoning_blocks) + len(compactions),
- "error_count": error_count,
- "warning_count": warning_count,
- "issue_count": issue_count,
- "start_ms": start_ms,
- "end_ms": end_ms,
- "duration_ms": duration_ms,
- "input_tokens": int(input_tokens or 0),
- "output_tokens": int(output_tokens or 0),
- "reasoning_tokens": int(reasoning_tokens or 0),
- "cache_read": int(cache_read or 0),
- "cost": cost,
- "model_id": model_id,
- "model_provider": model_provider,
- "agent": agent,
- "outcome": outcome,
- "cwd": cwd or "",
- "session_id": session_id,
- "source_size": source_size,
- }
- def _stat_card(label, value, sub="", tone="") -> str:
- sub_html = f'<span class="stat-sub">{esc(sub)}</span>' if sub else ""
- tone_class = f" {tone}" if tone else ""
- return (
- f'<div class="stat{tone_class}">'
- f'<span class="stat-label">{esc(label)}</span>'
- f'<span class="stat-value">{esc(value)}</span>'
- f"{sub_html}"
- "</div>"
- )
- def render_hero(stats, title) -> str:
- outcome = str(stats["outcome"])
- outcome_lower = outcome.lower()
- if outcome_lower in ("succeeded", "success", "completed"):
- outcome_tone = "ok"
- elif "fail" in outcome_lower or "error" in outcome_lower:
- outcome_tone = "error"
- else:
- outcome_tone = "warn"
- pills = [
- f'<span class="pill model" title="{attr(stats["model_provider"])}">{esc(stats["model_id"])}</span>',
- f'<span class="pill">agent: {esc(stats["agent"])}</span>',
- f'<span class="pill outcome {outcome_tone}">{esc(outcome)}</span>',
- ]
- if stats["cwd"]:
- pills.append(
- f'<span class="pill mono" title="{attr(stats["cwd"])}">{esc(truncate(stats["cwd"], 70))}</span>'
- )
- 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"}'
- token_sub = f'{fmt_int(stats["input_tokens"])} in · {fmt_int(stats["output_tokens"])} out'
- if stats["cache_read"]:
- token_sub += f' · {short_num(stats["cache_read"])} cache'
- cards = [
- _stat_card("Duration", fmt_duration(stats["duration_ms"]) or "—", fmt_date(stats["start_ms"], short=True)),
- _stat_card("Prompts", f'{stats["prompt_count"]:,}', "user turns"),
- _stat_card("Responses", f'{stats["response_count"]:,}', "assistant text blocks"),
- _stat_card("Tool runs", f'{stats["tool_total"]:,}', f'{len(stats["tool_counts"])} tool kinds'),
- _stat_card("Tokens", f'{compact_num(stats["input_tokens"])} / {compact_num(stats["output_tokens"])}', token_sub),
- _stat_card("Cost", fmt_cost(stats["cost"]), "session cost"),
- _stat_card("Issues", f'{stats["issue_count"]:,}', issues_sub, "error" if stats["error_count"] else ("warn" if stats["warning_count"] else "")),
- ]
- mix_pills = []
- for name, count in sorted(stats["tool_counts"].items(), key=lambda item: (-item[1], item[0])):
- accent = TOOL_COLORS.get(name, DEFAULT_TOOL_COLOR)
- mix_pills.append(
- f'<span class="mix-pill" style="--accent:{accent}">'
- '<span class="mix-dot" aria-hidden="true"></span>'
- f'<span class="mix-name">{esc(name)}</span>'
- f'<b>{count:,}</b>'
- "</span>"
- )
- mix_html = (
- '<div class="tool-mix"><span class="mix-label">Tool mix</span>'
- + "".join(mix_pills)
- + "</div>"
- if mix_pills
- else ""
- )
- meta_bits = []
- if stats["start_ms"]:
- meta_bits.append(f'Started {esc(fmt_date(stats["start_ms"]))}')
- if stats["end_ms"]:
- meta_bits.append(f'Ended {esc(fmt_date(stats["end_ms"]))}')
- meta_bits.append(f'{stats["message_count"]:,} messages')
- if stats["source_size"]:
- meta_bits.append(f'{fmt_bytes(stats["source_size"])} log')
- meta_bits.append(f'Session {stats["session_id"]}')
- meta_html = '<span class="sep">·</span>'.join(f"<span>{bit}</span>" for bit in meta_bits)
- return (
- '<header class="hero">'
- '<div class="hero-main">'
- '<div class="eyebrow"><span class="eyebrow-dot" aria-hidden="true"></span>OpenCode session log</div>'
- f"<h1>{esc(title)}</h1>"
- f'<div class="subline">{"".join(pills)}</div>'
- f'<div class="hero-meta">{meta_html}</div>'
- "</div>"
- f'<div class="stats">{"".join(cards)}</div>'
- f"{mix_html}"
- "</header>"
- )
- def render_toolbar(stats) -> str:
- chips = [
- ("all", "All", stats["total_blocks"]),
- ("prompt", "Prompts", stats["prompt_count"]),
- ("response", "Responses", stats["response_count"]),
- ("tool", "Tool runs", stats["tool_total"]),
- ("reasoning", "Reasoning", stats["reasoning_count"]),
- ("issue", "Issues", stats["issue_count"]),
- ]
- chip_html = []
- for value, label, count in chips:
- active = " active" if value == "all" else ""
- pressed = "true" if value == "all" else "false"
- chip_html.append(
- f'<button class="chip{active}" type="button" data-filter="{value}" '
- f'aria-pressed="{pressed}">{esc(label)}<span class="count">{count:,}</span></button>'
- )
- return (
- '<div class="toolbar" role="region" aria-label="Session controls">'
- '<div class="toolbar-inner">'
- f'<div class="filters" role="group" aria-label="Filter blocks">{"".join(chip_html)}</div>'
- '<div class="toolbar-actions">'
- '<label class="search" for="search">'
- '<span class="search-icon" aria-hidden="true">⌕</span>'
- '<input id="search" type="search" placeholder="Search session… (press /)" autocomplete="off" spellcheck="false">'
- "</label>"
- f'<span class="visible-count" id="visible-count" aria-live="polite">{stats["total_blocks"]:,} blocks</span>'
- '<button class="btn" type="button" data-expand="all">Expand all</button>'
- '<button class="btn" type="button" data-expand="none">Collapse all</button>'
- '<button class="icon-btn" id="theme-toggle" type="button" aria-label="Toggle theme" title="Toggle theme">☀</button>'
- "</div>"
- "</div>"
- "</div>"
- )
- def default_title(messages) -> str:
- for msg in messages:
- if not isinstance(msg, dict) or msg.get("type") != "user":
- continue
- text = msg.get("text") or (msg.get("metadata") or {}).get("displayText") or ""
- first = text.strip().splitlines()[0].strip() if text.strip() else ""
- first = re.sub(r"^#+\s*", "", first)
- if first:
- return truncate(first, 100)
- return "OpenCode session"
- def _html_head(title: str) -> str:
- return (
- "<!doctype html>\n"
- '<html lang="en">\n'
- "<head>\n"
- '<meta charset="utf-8">\n'
- '<meta name="viewport" content="width=device-width, initial-scale=1">\n'
- f"<title>{esc(title)}</title>\n"
- "<script>\n"
- "try {\n"
- " const stored = localStorage.getItem('oc-theme');\n"
- " const theme = stored || (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');\n"
- " document.documentElement.dataset.theme = theme;\n"
- "} catch (e) {}\n"
- "</script>\n"
- "<style>\n"
- + CSS
- + "\n</style>\n"
- "</head>\n"
- '<body id="top">\n'
- '<a class="skip-link" href="#content">Skip to content</a>\n'
- )
- def _html_tail() -> str:
- return "\n<script>\n" + JS + "\n</script>\n</body>\n</html>\n"
- def render_page(stats, turns_html, title, source_name, source_size) -> str:
- generated = datetime.now().astimezone().strftime("%d %b %Y, %H:%M %Z")
- if turns_html:
- main_body = "".join(turns_html)
- empty = (
- '<div id="empty" class="empty-state" hidden>'
- "No blocks match the current filter or search."
- "</div>"
- )
- else:
- main_body = '<div class="empty-state">No messages found in this session log.</div>'
- empty = ""
- source_bits = f"<code>{esc(source_name)}</code>"
- if source_size:
- source_bits += f" · {fmt_bytes(source_size)}"
- footer = (
- '<footer class="footer">'
- f"<div>Generated from {source_bits} · {esc(generated)}</div>"
- f'<div>Session <code>{esc(stats["session_id"])}</code> · {stats["message_count"]:,} messages · '
- '<a href="#top">Back to top ↑</a></div>'
- "</footer>"
- )
- return (
- _html_head(f"{title} · OpenCode session")
- + render_hero(stats, title)
- + render_toolbar(stats)
- + '<main id="content">'
- + main_body
- + empty
- + "</main>"
- + '<noscript><div class="noscript">Filters and search need JavaScript. All content is shown below.</div></noscript>'
- + footer
- + _html_tail()
- )
- def render_session(data, title=None, source_name=None, source_size=None) -> str:
- if isinstance(data, list):
- info = {}
- messages = data
- elif isinstance(data, dict):
- info = data.get("info") or {}
- messages = data.get("messages") or []
- else:
- raise ValueError("Expected a session object or a list of messages")
- if not isinstance(messages, list):
- raise ValueError("Session 'messages' is not a list")
- if not isinstance(info, dict):
- info = {}
- stats = collect_stats(info, messages, source_size=source_size)
- ctx = {
- "ids": {},
- "start_ms": stats["start_ms"],
- "cwd": stats["cwd"],
- "prompt_count": stats["prompt_count"],
- "model": stats["model_id"],
- }
- turns = group_turns(messages)
- turns_html = [render_turn(turn, index, ctx) for index, turn in enumerate(turns, start=1)]
- page_title = title or default_title(messages)
- return render_page(
- stats,
- turns_html,
- page_title,
- source_name or "session log",
- source_size,
- )
- def main(argv=None) -> int:
- parser = argparse.ArgumentParser(
- description="Render an OpenCode session JSON log as a single self-contained HTML file."
- )
- parser.add_argument(
- "input",
- nargs="?",
- default="opencode-sesion-log.json",
- help="session JSON file (use - for stdin)",
- )
- parser.add_argument("-o", "--output", help="output HTML file (use - for stdout)")
- parser.add_argument("--title", help="page title (defaults to the first prompt)")
- parser.add_argument("--open", action="store_true", help="open the generated page in a browser")
- args = parser.parse_args(argv)
- try:
- if args.input == "-":
- raw = sys.stdin.read()
- source_name = "stdin"
- source_size = len(raw.encode("utf-8"))
- else:
- input_path = Path(args.input)
- raw = input_path.read_text(encoding="utf-8")
- source_name = input_path.name
- source_size = input_path.stat().st_size
- data = json.loads(raw)
- page = render_session(
- data,
- title=args.title,
- source_name=source_name,
- source_size=source_size,
- )
- except (OSError, ValueError, json.JSONDecodeError) as exc:
- print(f"error: {exc}", file=sys.stderr)
- return 2
- if args.output == "-":
- sys.stdout.write(page)
- return 0
- if args.output:
- output_path = Path(args.output)
- elif args.input == "-":
- output_path = Path("opencode-session.html")
- else:
- output_path = Path(args.input).with_name("opencode-session.html")
- try:
- output_path.write_text(page, encoding="utf-8")
- except OSError as exc:
- print(f"error: {exc}", file=sys.stderr)
- return 2
- print(f"Wrote {output_path} ({fmt_bytes(len(page.encode('utf-8')))})")
- if args.open:
- webbrowser.open(output_path.resolve().as_uri())
- return 0
- # ---------------------------------------------------------------------------
- # Styles
- # ---------------------------------------------------------------------------
- CSS = r"""
- :root {
- color-scheme: dark;
- --sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
- --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
- --bg: #0a0d13;
- --bg-soft: #0e121a;
- --panel: #111722;
- --panel-2: #151c29;
- --panel-3: #1a2231;
- --line: #232c3d;
- --line-strong: #33405a;
- --fg: #e7ebf3;
- --muted: #99a4b8;
- --faint: #6f7b90;
- --accent: #7dd3fc;
- --accent-2: #a78bfa;
- --green: #34d399;
- --red: #fb7185;
- --amber: #fbbf24;
- --blue: #60a5fa;
- --pink: #f472b6;
- --code-bg: #080b10;
- --mark: #fde68a;
- --mark-fg: #1a2231;
- --shadow: 0 18px 50px rgba(0, 0, 0, .28);
- }
- :root[data-theme="light"] {
- color-scheme: light;
- --bg: #f5f7fb;
- --bg-soft: #eef2f8;
- --panel: #ffffff;
- --panel-2: #f8fafc;
- --panel-3: #eef2f7;
- --line: #e2e8f0;
- --line-strong: #cbd5e1;
- --fg: #172033;
- --muted: #5b6b82;
- --faint: #8794a8;
- --accent: #0284c7;
- --accent-2: #7c3aed;
- --green: #059669;
- --red: #e11d48;
- --amber: #b45309;
- --blue: #2563eb;
- --pink: #db2777;
- --code-bg: #f1f5f9;
- --mark: #fef08a;
- --mark-fg: #172033;
- --shadow: 0 18px 50px rgba(15, 23, 42, .08);
- }
- * { box-sizing: border-box; }
- html { scroll-behavior: smooth; }
- body {
- margin: 0;
- background-color: var(--bg);
- background-image:
- radial-gradient(1200px 620px at 12% -12%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 60%),
- radial-gradient(900px 520px at 100% 0%, color-mix(in srgb, var(--accent-2) 10%, transparent), transparent 58%);
- background-attachment: fixed;
- color: var(--fg);
- font-family: var(--sans);
- font-size: 15px;
- line-height: 1.6;
- -webkit-font-smoothing: antialiased;
- text-rendering: optimizeLegibility;
- }
- ::selection { background: color-mix(in srgb, var(--accent) 35%, transparent); }
- a { color: inherit; }
- summary { list-style: none; cursor: pointer; }
- summary::-webkit-details-marker { display: none; }
- .skip-link {
- position: absolute;
- left: -9999px;
- top: 0;
- z-index: 100;
- background: var(--panel);
- color: var(--fg);
- padding: 10px 14px;
- border-radius: 0 0 8px 0;
- border: 1px solid var(--line);
- }
- .skip-link:focus { left: 0; }
- .hero { max-width: 1120px; margin: 0 auto; padding: 56px 24px 28px; }
- .hero-main { max-width: 900px; }
- .eyebrow {
- display: flex;
- align-items: center;
- gap: 8px;
- color: var(--muted);
- font-size: 11px;
- font-weight: 650;
- letter-spacing: .16em;
- text-transform: uppercase;
- }
- .eyebrow-dot {
- width: 7px;
- height: 7px;
- border-radius: 50%;
- background: var(--green);
- box-shadow: 0 0 0 4px color-mix(in srgb, var(--green) 14%, transparent);
- }
- h1 {
- margin: 12px 0 14px;
- font-size: clamp(1.7rem, 3.4vw, 2.6rem);
- line-height: 1.12;
- letter-spacing: -.025em;
- font-weight: 750;
- text-wrap: balance;
- }
- .subline { display: flex; flex-wrap: wrap; gap: 7px; }
- .pill {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- border: 1px solid var(--line);
- background: var(--panel);
- color: var(--muted);
- border-radius: 999px;
- padding: 4px 10px;
- font-size: 12px;
- }
- .pill.model {
- color: var(--accent);
- border-color: color-mix(in srgb, var(--accent) 34%, var(--line));
- background: color-mix(in srgb, var(--accent) 9%, var(--panel));
- }
- .pill.outcome.ok {
- color: var(--green);
- border-color: color-mix(in srgb, var(--green) 34%, var(--line));
- background: color-mix(in srgb, var(--green) 9%, var(--panel));
- }
- .pill.outcome.error {
- color: var(--red);
- border-color: color-mix(in srgb, var(--red) 34%, var(--line));
- background: color-mix(in srgb, var(--red) 9%, var(--panel));
- }
- .pill.outcome.warn {
- color: var(--amber);
- border-color: color-mix(in srgb, var(--amber) 34%, var(--line));
- background: color-mix(in srgb, var(--amber) 9%, var(--panel));
- }
- .pill.mono { font-family: var(--mono); font-size: 11.5px; }
- .hero-meta {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
- margin-top: 12px;
- color: var(--faint);
- font-size: 12px;
- }
- .hero-meta .sep { opacity: .5; }
- .stats {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(125px, 1fr));
- gap: 10px;
- margin-top: 26px;
- }
- .stat {
- min-width: 0;
- border: 1px solid var(--line);
- background: linear-gradient(180deg, var(--panel), var(--panel-2));
- border-radius: 12px;
- padding: 12px 13px;
- }
- .stat-label {
- display: block;
- color: var(--faint);
- font-size: 10px;
- font-weight: 650;
- letter-spacing: .12em;
- text-transform: uppercase;
- }
- .stat-value {
- display: block;
- margin-top: 3px;
- font-size: 1.05rem;
- font-weight: 700;
- letter-spacing: -.01em;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .stat-sub {
- display: block;
- margin-top: 2px;
- color: var(--muted);
- font-size: 10.5px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .stat.error .stat-value { color: var(--red); }
- .stat.warn .stat-value { color: var(--amber); }
- .tool-mix { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-top: 14px; }
- .mix-label {
- margin-right: 2px;
- color: var(--faint);
- font-size: 10px;
- font-weight: 650;
- letter-spacing: .12em;
- text-transform: uppercase;
- }
- .mix-pill {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- border: 1px solid var(--line);
- background: var(--panel);
- border-radius: 999px;
- padding: 3px 9px;
- color: var(--muted);
- font-size: 11.5px;
- }
- .mix-dot {
- width: 7px;
- height: 7px;
- border-radius: 50%;
- background: var(--accent);
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent);
- }
- .mix-name { color: var(--fg); font-family: var(--mono); font-size: 11px; }
- .mix-pill b { color: var(--muted); font-weight: 600; font-variant-numeric: tabular-nums; }
- .toolbar {
- position: sticky;
- top: 0;
- z-index: 50;
- border-bottom: 1px solid var(--line);
- background: color-mix(in srgb, var(--bg) 82%, transparent);
- -webkit-backdrop-filter: blur(18px) saturate(1.3);
- backdrop-filter: blur(18px) saturate(1.3);
- }
- .toolbar-inner {
- max-width: 1120px;
- margin: 0 auto;
- padding: 10px 24px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- flex-wrap: wrap;
- }
- .filters { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
- .chip {
- appearance: none;
- display: inline-flex;
- align-items: center;
- gap: 6px;
- border: 1px solid var(--line);
- background: var(--panel);
- color: var(--muted);
- border-radius: 999px;
- padding: 6px 11px;
- font: inherit;
- font-size: 12px;
- cursor: pointer;
- transition: color .15s, border-color .15s, background .15s;
- }
- .chip:hover { color: var(--fg); border-color: var(--line-strong); }
- .chip.active {
- color: var(--fg);
- background: color-mix(in srgb, var(--accent) 16%, var(--panel));
- border-color: color-mix(in srgb, var(--accent) 48%, var(--line));
- }
- .chip .count { color: var(--faint); font-size: 10.5px; font-variant-numeric: tabular-nums; }
- .chip.active .count { color: var(--accent); }
- .toolbar-actions { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
- .search {
- display: flex;
- align-items: center;
- gap: 7px;
- height: 34px;
- padding: 0 10px;
- border: 1px solid var(--line);
- background: var(--panel);
- border-radius: 9px;
- transition: border-color .15s, box-shadow .15s;
- }
- .search:focus-within {
- border-color: color-mix(in srgb, var(--accent) 55%, var(--line));
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);
- }
- .search-icon { color: var(--faint); font-size: 16px; line-height: 1; }
- .search input {
- width: 215px;
- border: 0;
- outline: 0;
- background: transparent;
- color: var(--fg);
- font: inherit;
- font-size: 12.5px;
- }
- .search input::placeholder { color: var(--faint); }
- .visible-count { color: var(--faint); font-size: 11px; font-variant-numeric: tabular-nums; white-space: nowrap; }
- .btn, .icon-btn {
- appearance: none;
- height: 34px;
- border: 1px solid var(--line);
- background: var(--panel);
- color: var(--muted);
- border-radius: 9px;
- padding: 0 10px;
- font: inherit;
- font-size: 12px;
- cursor: pointer;
- transition: color .15s, border-color .15s;
- }
- .btn:hover, .icon-btn:hover { color: var(--fg); border-color: var(--line-strong); }
- .icon-btn { width: 34px; padding: 0; font-size: 15px; }
- main { max-width: 1120px; margin: 0 auto; padding: 30px 24px 90px; }
- .turn { position: relative; }
- .turn + .turn { margin-top: 38px; padding-top: 38px; border-top: 1px solid var(--line); }
- .turn-start { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; color: var(--muted); font-size: 12px; }
- .prompt {
- position: relative;
- border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--line));
- background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 7%, var(--panel)), var(--panel));
- border-radius: 16px;
- padding: 18px 20px;
- box-shadow: var(--shadow);
- scroll-margin-top: 110px;
- }
- .prompt-head, .response-head {
- display: flex;
- align-items: center;
- gap: 9px;
- flex-wrap: wrap;
- }
- .prompt-head { margin-bottom: 10px; color: var(--muted); font-size: 12px; }
- .prompt-label { color: var(--fg); font-weight: 650; }
- .prompt-label .of { color: var(--faint); font-weight: 500; }
- .avatar {
- width: 27px;
- height: 27px;
- border-radius: 9px;
- display: inline-grid;
- place-items: center;
- flex: none;
- font-size: 10px;
- font-weight: 750;
- letter-spacing: .02em;
- }
- .avatar.you {
- color: var(--accent);
- background: color-mix(in srgb, var(--accent) 16%, var(--panel));
- border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--line));
- }
- .avatar.ai {
- color: var(--green);
- background: color-mix(in srgb, var(--green) 14%, var(--panel));
- border: 1px solid color-mix(in srgb, var(--green) 30%, var(--line));
- }
- .tiny-pill {
- display: inline-flex;
- align-items: center;
- border: 1px solid var(--line);
- background: var(--panel-2);
- color: var(--muted);
- border-radius: 999px;
- padding: 1px 7px;
- font-size: 10.5px;
- }
- .anchor {
- margin-left: auto;
- color: var(--faint);
- text-decoration: none;
- opacity: 0;
- transition: opacity .15s;
- font-size: 12px;
- }
- .prompt:hover .anchor, .response:hover .anchor, .anchor:focus-visible { opacity: 1; }
- .anchor:hover { color: var(--accent); }
- .turn-body {
- display: flex;
- flex-direction: column;
- gap: 11px;
- margin-top: 16px;
- margin-left: 13px;
- padding-left: 24px;
- border-left: 1px solid var(--line);
- }
- .turn-body > [data-kind] { position: relative; scroll-margin-top: 110px; }
- .turn-body > [data-kind]::before {
- content: "";
- position: absolute;
- left: -29px;
- top: 17px;
- width: 7px;
- height: 7px;
- border-radius: 50%;
- background: var(--accent, var(--muted));
- box-shadow: 0 0 0 3px var(--bg);
- }
- .response {
- --accent: var(--green);
- border: 1px solid var(--line);
- background: var(--panel);
- border-radius: 14px;
- padding: 15px 18px;
- }
- .response-head { margin-bottom: 9px; color: var(--muted); font-size: 12px; }
- .who { color: var(--fg); font-weight: 650; }
- .who-sub { color: var(--faint); font-family: var(--mono); font-size: 10.5px; }
- .reasoning {
- --accent: var(--faint);
- border: 1px dashed var(--line-strong);
- background: color-mix(in srgb, var(--panel) 70%, transparent);
- border-radius: 12px;
- }
- .reasoning > summary {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 9px 13px;
- color: var(--muted);
- font-size: 12px;
- }
- .reasoning-body { padding: 0 14px 13px; color: var(--muted); font-size: 13.5px; }
- .reason-label { color: var(--fg); font-weight: 600; }
- .tool {
- --accent: var(--muted);
- border: 1px solid var(--line);
- background: var(--panel);
- border-radius: 12px;
- overflow: hidden;
- }
- .tool[open] {
- border-color: color-mix(in srgb, var(--accent) 42%, var(--line));
- box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 10%, transparent);
- }
- .tool > summary {
- display: flex;
- align-items: center;
- gap: 9px;
- min-height: 44px;
- padding: 10px 12px;
- }
- .tool-icon {
- width: 26px;
- height: 26px;
- border-radius: 8px;
- display: inline-grid;
- place-items: center;
- flex: none;
- color: var(--accent);
- background: color-mix(in srgb, var(--accent) 14%, var(--panel-2));
- border: 1px solid color-mix(in srgb, var(--accent) 26%, var(--line));
- font-family: var(--mono);
- font-size: 10.5px;
- font-weight: 700;
- }
- .tool-name { flex: none; color: var(--accent); font-family: var(--mono); font-size: 11.5px; font-weight: 650; }
- .tool-summary {
- flex: 1;
- min-width: 0;
- color: var(--fg);
- font-size: 12.5px;
- opacity: .9;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .tool-badges { display: flex; align-items: center; gap: 5px; flex: none; }
- .badge {
- display: inline-flex;
- align-items: center;
- border: 1px solid var(--line);
- background: var(--panel-2);
- color: var(--muted);
- border-radius: 999px;
- padding: 1px 7px;
- font-size: 10px;
- line-height: 1.6;
- white-space: nowrap;
- }
- .badge.ok {
- color: var(--green);
- border-color: color-mix(in srgb, var(--green) 30%, var(--line));
- background: color-mix(in srgb, var(--green) 8%, var(--panel));
- }
- .badge.warn {
- color: var(--amber);
- border-color: color-mix(in srgb, var(--amber) 30%, var(--line));
- background: color-mix(in srgb, var(--amber) 8%, var(--panel));
- }
- .badge.error {
- color: var(--red);
- border-color: color-mix(in srgb, var(--red) 32%, var(--line));
- background: color-mix(in srgb, var(--red) 8%, var(--panel));
- }
- .dur { color: var(--faint); font-size: 10.5px; font-variant-numeric: tabular-nums; }
- .chev { flex: none; color: var(--faint); font-size: 15px; line-height: 1; transition: transform .15s ease; }
- details[open] > summary .chev { transform: rotate(90deg); }
- .tool-body {
- display: flex;
- flex-direction: column;
- gap: 11px;
- border-top: 1px solid var(--line);
- padding: 12px 14px 14px;
- }
- .io { min-width: 0; }
- .io-head {
- display: flex;
- align-items: center;
- gap: 7px;
- flex-wrap: wrap;
- min-height: 20px;
- margin-bottom: 5px;
- }
- .io-label { color: var(--faint); font-size: 10px; font-weight: 650; letter-spacing: .12em; text-transform: uppercase; }
- .path {
- max-width: 60%;
- color: var(--muted);
- font-family: var(--mono);
- font-size: 11px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .hint { color: var(--faint); font-size: 10.5px; }
- .copy {
- margin-left: auto;
- appearance: none;
- border: 1px solid var(--line);
- background: var(--panel-2);
- color: var(--muted);
- border-radius: 6px;
- padding: 2px 7px;
- font: inherit;
- font-size: 10.5px;
- cursor: pointer;
- opacity: .65;
- transition: opacity .15s, color .15s, border-color .15s;
- }
- .copy:hover { opacity: 1; color: var(--fg); border-color: var(--line-strong); }
- .calls { display: flex; flex-direction: column; gap: 5px; }
- .call {
- display: flex;
- align-items: center;
- gap: 7px;
- flex-wrap: wrap;
- border: 1px solid var(--line);
- background: var(--panel-2);
- border-radius: 8px;
- padding: 6px 8px;
- }
- .call-name { color: var(--accent-2); font-family: var(--mono); font-size: 11px; }
- .call code { color: var(--muted); font-size: 11px; }
- .kv { border: 1px solid var(--line); background: var(--panel-2); border-radius: 8px; overflow: hidden; }
- .kv-row { display: flex; gap: 10px; padding: 6px 9px; border-top: 1px solid var(--line); font-size: 12px; }
- .kv-row:first-child { border-top: 0; }
- .kv-key { min-width: 52px; padding-top: 2px; color: var(--faint); font-size: 10px; letter-spacing: .1em; text-transform: uppercase; }
- .kv-val { min-width: 0; overflow-wrap: anywhere; }
- .kv-val code { color: var(--muted); font-size: 11.5px; }
- pre { margin: 0; }
- pre.code, pre.output, pre.diff {
- border: 1px solid var(--line);
- background: var(--code-bg);
- border-radius: 9px;
- padding: 10px 12px;
- overflow: auto;
- max-height: 430px;
- font-family: var(--mono);
- font-size: 12.5px;
- line-height: 1.55;
- tab-size: 2;
- }
- pre.code { white-space: pre; }
- pre.output { white-space: pre-wrap; overflow-wrap: anywhere; }
- pre.diff { white-space: pre; padding: 8px 0; }
- pre.error-text { color: var(--red); }
- .prompt-char { color: var(--green); user-select: none; }
- .diff-line { display: block; padding: 0 10px; }
- .diff-add { color: var(--green); background: color-mix(in srgb, var(--green) 9%, transparent); }
- .diff-del { color: var(--red); background: color-mix(in srgb, var(--red) 9%, transparent); }
- .diff-hunk { color: var(--accent-2); background: color-mix(in srgb, var(--accent-2) 7%, transparent); }
- .diff-meta { color: var(--faint); }
- .diff-ctx { color: var(--muted); }
- .md { min-width: 0; }
- .md > :first-child { margin-top: 0; }
- .md > :last-child { margin-bottom: 0; }
- .md p { margin: .62em 0; }
- .md h1, .md h2, .md h3, .md h4, .md h5, .md h6 {
- margin: 1.05em 0 .42em;
- line-height: 1.28;
- letter-spacing: -.01em;
- }
- .md h1 { font-size: 1.35em; }
- .md h2 { font-size: 1.2em; }
- .md h3 { font-size: 1.08em; }
- .md h4, .md h5, .md h6 { font-size: 1em; }
- .md ul, .md ol { margin: .55em 0; padding-left: 1.55em; }
- .md li { margin: .22em 0; }
- .md li > p { margin: .18em 0; }
- .md code {
- border: 1px solid var(--line);
- background: var(--code-bg);
- border-radius: 5px;
- padding: 1px 5px;
- font-family: var(--mono);
- font-size: .9em;
- }
- .md pre {
- margin: .8em 0;
- border: 1px solid var(--line);
- background: var(--code-bg);
- border-radius: 9px;
- padding: 10px 12px;
- overflow: auto;
- max-height: 430px;
- }
- .md pre code { border: 0; background: none; padding: 0; font-size: 12.5px; line-height: 1.55; }
- .md blockquote {
- margin: .8em 0;
- padding: .15em 0 .15em 1em;
- border-left: 3px solid color-mix(in srgb, var(--accent) 45%, var(--line));
- color: var(--muted);
- }
- .md hr { border: 0; border-top: 1px solid var(--line); margin: 1.1em 0; }
- .md a {
- color: var(--accent);
- text-decoration: none;
- border-bottom: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
- }
- .md a:hover { border-bottom-color: var(--accent); }
- .md table { width: 100%; border-collapse: collapse; font-size: .92em; }
- .md th, .md td { border: 1px solid var(--line); padding: 6px 9px; text-align: left; vertical-align: top; }
- .md th { background: var(--panel-2); font-weight: 650; }
- .md tbody tr:nth-child(even) td { background: color-mix(in srgb, var(--panel-2) 55%, transparent); }
- .table-wrap { margin: .8em 0; border-radius: 8px; overflow-x: auto; }
- .prompt-md { font-size: 15.5px; }
- .compaction {
- --accent: var(--accent-2);
- border: 1px solid color-mix(in srgb, var(--accent-2) 35%, var(--line));
- background: linear-gradient(180deg, color-mix(in srgb, var(--accent-2) 7%, var(--panel)), var(--panel));
- border-radius: 12px;
- }
- .compaction > summary {
- display: flex;
- align-items: center;
- gap: 8px;
- flex-wrap: wrap;
- padding: 10px 13px;
- color: var(--muted);
- font-size: 12px;
- }
- .compact-icon { color: var(--accent-2); font-size: 15px; }
- .compact-title { color: var(--fg); font-weight: 650; }
- .compaction-body { border-top: 1px solid var(--line); padding: 12px 14px 14px; }
- .compaction-note { margin: 0 0 10px; color: var(--faint); font-size: 11.5px; font-style: italic; }
- .attachments, .unknown-event {
- margin-top: 12px;
- border: 1px dashed var(--line-strong);
- background: var(--panel-2);
- border-radius: 10px;
- }
- .attachments > summary, .unknown-event > summary {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 8px 11px;
- color: var(--muted);
- font-size: 11.5px;
- }
- .attachments pre, .unknown-event pre { margin: 0 11px 11px; }
- .unknown-content { border: 1px dashed var(--line-strong); border-radius: 10px; padding: 10px; }
- .empty-note { color: var(--faint); font-size: 11.5px; font-style: italic; }
- .muted { color: var(--muted); }
- .empty-state {
- margin-top: 20px;
- border: 1px dashed var(--line-strong);
- background: var(--panel);
- border-radius: 12px;
- padding: 30px;
- color: var(--muted);
- text-align: center;
- }
- .noscript {
- max-width: 1120px;
- margin: 16px auto 0;
- padding: 10px 14px;
- border: 1px solid color-mix(in srgb, var(--amber) 30%, var(--line));
- background: color-mix(in srgb, var(--amber) 7%, var(--panel));
- border-radius: 9px;
- color: var(--muted);
- font-size: 12px;
- }
- .footer {
- max-width: 1120px;
- margin: 0 auto;
- padding: 0 24px 50px;
- display: flex;
- flex-wrap: wrap;
- justify-content: space-between;
- gap: 8px;
- color: var(--faint);
- font-size: 11.5px;
- }
- .footer code { color: var(--muted); }
- .footer a { color: var(--muted); }
- ::highlight(search) { background: var(--mark); color: var(--mark-fg); }
- @media (max-width: 1000px) and (min-width: 781px) {
- .stats { grid-template-columns: repeat(4, minmax(0, 1fr)); }
- }
- @media (max-width: 780px) {
- .hero { padding: 36px 16px 22px; }
- .toolbar-inner { padding: 9px 16px; }
- main { padding: 22px 16px 70px; }
- .footer { padding: 0 16px 40px; }
- .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
- .toolbar-actions { width: 100%; }
- .search { flex: 1 1 100%; }
- .search input { width: 100%; }
- .prompt { padding: 15px 16px; }
- .turn-body { margin-left: 6px; padding-left: 17px; }
- .turn-body > [data-kind]::before { left: -21px; }
- .tool-summary { font-size: 12px; }
- }
- @media (max-width: 520px) {
- .tool > summary { flex-wrap: wrap; }
- .tool-summary { flex: 1 1 100%; order: 10; white-space: normal; }
- .tool-badges { margin-left: auto; }
- .prompt-head, .response-head { gap: 7px; }
- .anchor { display: none; }
- }
- @media (prefers-reduced-motion: reduce) {
- html { scroll-behavior: auto; }
- * { transition: none !important; }
- }
- @media print {
- .toolbar, .copy, .anchor, .skip-link, .noscript { display: none !important; }
- body { background: #fff; color: #000; }
- .prompt, .response, .tool, .reasoning, .compaction { box-shadow: none; break-inside: avoid; }
- .turn-body { border-left-color: #ccc; }
- }
- """
- # ---------------------------------------------------------------------------
- # Behavior
- # ---------------------------------------------------------------------------
- JS = r"""
- (() => {
- 'use strict';
- const root = document.documentElement;
- const themeBtn = document.getElementById('theme-toggle');
- const search = document.getElementById('search');
- const chips = Array.from(document.querySelectorAll('.chip[data-filter]'));
- const items = Array.from(document.querySelectorAll('[data-kind]'));
- const turns = Array.from(document.querySelectorAll('.turn'));
- const empty = document.getElementById('empty');
- const visibleCount = document.getElementById('visible-count');
- const content = document.getElementById('content');
- const state = { filter: 'all', query: '' };
- // Pre-compute searchable text without the page chrome (labels, badges,
- // buttons) so searching for "copy" does not match every tool card.
- const searchIndex = new Map();
- items.forEach((element) => {
- const clone = element.cloneNode(true);
- clone.querySelectorAll(
- '.io-head, .tool-badges, .chev, .avatar, .anchor, .time, .tiny-pill, ' +
- '.prompt-label, .who, .who-sub, .reason-label, .compact-title, ' +
- '.compact-icon, .badge, .dur, .tool-name, .tool-icon, .attachments > summary'
- ).forEach((node) => node.remove());
- searchIndex.set(element, (clone.textContent || '').toLowerCase());
- });
- function setTheme(theme) {
- root.dataset.theme = theme;
- try { localStorage.setItem('oc-theme', theme); } catch (error) { /* ignore */ }
- if (themeBtn) {
- const label = theme === 'light' ? 'Switch to dark theme' : 'Switch to light theme';
- themeBtn.textContent = theme === 'light' ? '☾' : '☀';
- themeBtn.setAttribute('aria-label', label);
- themeBtn.title = label;
- }
- }
- if (themeBtn) {
- setTheme(root.dataset.theme || 'dark');
- themeBtn.addEventListener('click', () => {
- setTheme(root.dataset.theme === 'light' ? 'dark' : 'light');
- });
- }
- function highlight(query) {
- if (!window.CSS || !CSS.highlights || typeof Highlight === 'undefined' || !content) return;
- try {
- CSS.highlights.delete('search');
- if (!query) return;
- const ranges = [];
- const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
- let node;
- while ((node = walker.nextNode())) {
- if (!node.nodeValue || !node.parentElement) continue;
- if (node.parentElement.closest('[hidden]')) continue;
- const lower = node.nodeValue.toLowerCase();
- let index = lower.indexOf(query);
- while (index !== -1) {
- const range = new Range();
- range.setStart(node, index);
- range.setEnd(node, index + query.length);
- ranges.push(range);
- index = lower.indexOf(query, index + query.length);
- if (ranges.length >= 4000) break;
- }
- if (ranges.length >= 4000) break;
- }
- if (ranges.length) CSS.highlights.set('search', new Highlight(...ranges));
- } catch (error) {
- /* highlight is a progressive enhancement */
- }
- }
- function apply() {
- const query = state.query.trim().toLowerCase();
- let visible = 0;
- items.forEach((element) => {
- let show;
- if (state.filter === 'all') {
- show = true;
- } else if (state.filter === 'issue') {
- show = element.dataset.issue === '1';
- } else {
- show = element.dataset.kind === state.filter;
- }
- if (show && query) {
- show = (searchIndex.get(element) || '').includes(query);
- }
- element.hidden = !show;
- if (show) visible += 1;
- });
- turns.forEach((turn) => {
- const hasVisible = turn.querySelector('[data-kind]:not([hidden])');
- turn.hidden = !hasVisible;
- turn.querySelectorAll('.turn-body').forEach((body) => {
- body.hidden = !body.querySelector('[data-kind]:not([hidden])');
- });
- });
- if (empty) empty.hidden = visible !== 0;
- if (visibleCount) {
- visibleCount.textContent = `${visible} block${visible === 1 ? '' : 's'}`;
- }
- if (query.length >= 2) {
- items.forEach((element) => {
- if (!element.hidden && element.tagName === 'DETAILS') element.open = true;
- });
- }
- if (state.filter === 'reasoning') {
- items.forEach((element) => {
- if (!element.hidden && element.dataset.kind === 'reasoning' && element.tagName === 'DETAILS') {
- element.open = true;
- }
- });
- }
- if (state.filter === 'issue') {
- items.forEach((element) => {
- if (!element.hidden && element.dataset.issue === '1' && element.tagName === 'DETAILS') {
- element.open = true;
- }
- });
- }
- highlight(query);
- }
- chips.forEach((chip) => {
- chip.addEventListener('click', () => {
- state.filter = chip.dataset.filter;
- chips.forEach((candidate) => {
- const active = candidate === chip;
- candidate.classList.toggle('active', active);
- candidate.setAttribute('aria-pressed', active ? 'true' : 'false');
- });
- apply();
- });
- });
- let searchTimer;
- if (search) {
- search.addEventListener('input', () => {
- clearTimeout(searchTimer);
- searchTimer = setTimeout(() => {
- state.query = search.value;
- apply();
- }, 120);
- });
- }
- document.querySelectorAll('[data-expand]').forEach((button) => {
- button.addEventListener('click', () => {
- const open = button.dataset.expand === 'all';
- document.querySelectorAll('details').forEach((details) => {
- if (open && details.closest('[hidden]')) return;
- details.open = open;
- });
- });
- });
- document.addEventListener('click', async (event) => {
- if (!(event.target instanceof Element)) return;
- const button = event.target.closest('[data-copy]');
- if (!button) return;
- const target = document.getElementById(button.dataset.copy);
- if (!target) return;
- const text = target.innerText;
- const original = button.textContent;
- try {
- if (navigator.clipboard && window.isSecureContext) {
- await navigator.clipboard.writeText(text);
- } else {
- const textarea = document.createElement('textarea');
- textarea.value = text;
- textarea.style.position = 'fixed';
- textarea.style.opacity = '0';
- document.body.appendChild(textarea);
- textarea.select();
- document.execCommand('copy');
- textarea.remove();
- }
- button.textContent = 'Copied';
- } catch (error) {
- button.textContent = 'Copy failed';
- }
- setTimeout(() => { button.textContent = original; }, 1200);
- });
- document.addEventListener('keydown', (event) => {
- const tag = (document.activeElement && document.activeElement.tagName) || '';
- const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(tag);
- if ((event.key === '/' && !typing) || ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k')) {
- event.preventDefault();
- if (search) search.focus();
- }
- if (event.key === 'Escape' && document.activeElement === search) {
- search.value = '';
- state.query = '';
- apply();
- search.blur();
- }
- });
- apply();
- })();
- """
- if __name__ == "__main__":
- sys.exit(main())
Advertisement