diff --git a/README.md b/README.md index 606ea28..a8b5faa 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,9 @@ codex-transcripts # Convert a specific rollout file codex-transcripts json ~/.codex/sessions/2026/01/01/rollout-...jsonl -o ./out --open +# Render chat-style bubbles (user right, assistant left) +codex-transcripts json ~/.codex/sessions/2026/01/01/rollout-...jsonl -o ./out --style chat --open + # Emit normalized JSON instead of HTML codex-transcripts local --latest --format json -o ./out diff --git a/src/codex_transcripts/cli.py b/src/codex_transcripts/cli.py index 2af67c8..d62a9e0 100644 --- a/src/codex_transcripts/cli.py +++ b/src/codex_transcripts/cli.py @@ -138,6 +138,14 @@ def cli() -> None: show_default=True, help="Output format.", ) +@click.option( + "--style", + "output_style", + type=click.Choice(["viewer", "chat"], case_sensitive=False), + default="viewer", + show_default=True, + help="HTML style (viewer or chat).", +) def local_cmd( codex_home: Path | None, limit: int, @@ -154,6 +162,7 @@ def local_cmd( gist_public: bool, include_source: bool, output_format: str, + output_style: str, ) -> None: if show_all and cwd_only: raise click.ClickException("--all and --cwd are mutually exclusive.") @@ -171,6 +180,8 @@ def local_cmd( if output_format == "json" and (open_browser or gist): raise click.ClickException("--open/--gist are only supported for HTML output.") + if output_format == "json" and output_style != "viewer": + raise click.ClickException("--style is only supported for HTML output.") show_cwd = not cwd_only metrics = calculate_resume_style_metrics(rows, show_cwd=show_cwd) @@ -222,6 +233,7 @@ def local_cmd( out_dir, github_repo=repo, include_json=include_source, + style=output_style, ) _print_stats(stats) @@ -265,6 +277,7 @@ def local_cmd( subdir, github_repo=repo, include_json=include_source, + style=output_style, ) out_path = out_dir / "index.html" _print_stats(stats) @@ -403,6 +416,14 @@ def tui_cmd( show_default=True, help="Output format.", ) +@click.option( + "--style", + "output_style", + type=click.Choice(["viewer", "chat"], case_sensitive=False), + default="viewer", + show_default=True, + help="HTML style (viewer or chat).", +) def json_cmd( path: Path, output: str | None, @@ -413,12 +434,15 @@ def json_cmd( gist_public: bool, include_source: bool, output_format: str, + output_style: str, ) -> None: if not path.exists(): raise click.ClickException(f"File not found: {path}") if output_format == "json" and (open_browser or gist): raise click.ClickException("--open/--gist are only supported for HTML output.") + if output_format == "json" and output_style != "viewer": + raise click.ClickException("--style is only supported for HTML output.") out_dir, open_by_default = _ensure_output_dir(output, output_auto=output_auto, rollout_path=path) if output_format == "json": @@ -440,6 +464,7 @@ def json_cmd( out_dir, github_repo=repo, include_json=include_source, + style=output_style, ) _print_stats(stats) diff --git a/src/codex_transcripts/render.py b/src/codex_transcripts/render.py index 472c8b0..b468cf1 100644 --- a/src/codex_transcripts/render.py +++ b/src/codex_transcripts/render.py @@ -330,6 +330,91 @@ def render_message(log_type: str, message_json: str, timestamp: str, github_repo return _macros.message(role_class, role_label, msg_id, timestamp, content_html) +def _render_chat_blocks(content: list[Any]) -> str: + parts: list[str] = [] + for block in content: + if isinstance(block, dict): + btype = block.get("type", "") + if btype in {"text", "output_text", "input_text"}: + text = block.get("text", "") + if isinstance(text, str) and text.strip(): + parts.append(render_markdown_text(text)) + continue + if btype == "image": + source = block.get("source", {}) if isinstance(block.get("source"), dict) else {} + media_type = source.get("media_type", "image/png") + data = source.get("data", "") + parts.append(_macros.image_block(media_type, data)) + continue + continue + if isinstance(block, str) and block.strip(): + parts.append(render_markdown_text(block)) + return "".join(parts) + + +def extract_codex_user_request(text: str) -> str: + lines = text.splitlines() + for i, line in enumerate(lines): + stripped = line.strip() + if not stripped: + continue + normalized = stripped.lstrip("#").strip() + if not normalized: + continue + lowered = normalized.lower() + if lowered.startswith("my request for codex"): + remainder = normalized[len("my request for codex") :].lstrip(" :") + tail = "\n".join(lines[i + 1 :]) + if remainder and tail: + return (remainder + "\n" + tail).strip() + if remainder: + return remainder.strip() + if tail: + return tail.strip() + break + return text + + +def render_chat_message( + log_type: str, + message_json: str, + timestamp: str, + github_repo: str | None, + *, + meta_text: str | None = None, +) -> str: + if not message_json: + return "" + if log_type not in {"user", "assistant"}: + return "" + try: + message_data = json.loads(message_json) + except json.JSONDecodeError: + return "" + + if log_type == "user" and is_tool_result_message(message_data): + return "" + + content = message_data.get("content", "") + if isinstance(content, str): + if log_type == "user": + content = extract_codex_user_request(content) + if is_json_like(content): + content_html = format_json(content) + else: + content_html = render_markdown_text(content) + elif isinstance(content, list): + content_html = _render_chat_blocks(content) + else: + content_html = render_markdown_text(str(content)) + + if not content_html.strip(): + return "" + + msg_id = make_msg_id(timestamp) + return _macros.chat_message(log_type, msg_id, timestamp, content_html, meta_text or "") + + # CSS / JS are borrowed from claude-code-transcripts and intentionally embedded so # output is standalone (no external assets required). CSS = """ @@ -339,8 +424,8 @@ def render_message(log_type: str, message_json: str, timestamp: str, github_repo --card-bg: #ffffff; --user-bg: #e3f2fd; --user-border: #1976d2; - --assistant-bg: #f5f5f5; - --assistant-border: #9e9e9e; + --assistant-bg: #e8eaee; + --assistant-border: #7d8793; --thinking-bg: #fff8e1; --thinking-border: #ffc107; --thinking-text: #666; @@ -516,6 +601,7 @@ def render_message(log_type: str, message_json: str, timestamp: str, github_repo } * { box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg-color); color: var(--text-color); margin: 0; padding: 16px; line-height: 1.6; } +.chat-body .container { max-width: 100%; } .container { max-width: 800px; margin: 0 auto; } h1 { font-size: 1.5rem; margin-bottom: 24px; padding-bottom: 8px; border-bottom: 2px solid var(--user-border); } .header-row { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; border-bottom: 2px solid var(--user-border); padding-bottom: 8px; margin-bottom: 24px; } @@ -694,6 +780,31 @@ def render_message(log_type: str, message_json: str, timestamp: str, github_repo .minimap-tip-k { font-weight: 700; text-transform: uppercase; letter-spacing: 0.4px; font-size: 0.75rem; color: var(--text-muted); } .minimap-tip-prompt { margin-top: 4px; color: var(--text-color); } +/* Chat view */ +.chat-view { display: flex; flex-direction: column; gap: 24px; } +.chat-transcript { display: flex; flex-direction: column; gap: 24px; } +.chat-group { padding: 8px 0 4px 0; border-top: 1px solid var(--border-subtle); } +.chat-group:first-child { border-top: none; } +.chat-group-header { display: flex; align-items: baseline; gap: 12px; font-size: 0.85rem; color: var(--text-muted); margin-bottom: 8px; } +.chat-group-label { font-weight: 600; color: var(--user-border); } +.chat-messages { display: flex; flex-direction: column; gap: 8px; } +.chat-message { display: flex; flex-direction: column; align-items: flex-start; } +.chat-message.user { align-items: flex-end; } +.chat-message.assistant { align-items: flex-start; } +.chat-bubble { max-width: min(76%, 720px); padding: 10px 14px; border-radius: 18px; background: var(--assistant-bg); color: var(--text-color); box-shadow: 0 1px 2px var(--shadow-color); font-size: 1.2em; } +.chat-message.assistant .chat-bubble { border: 1px solid var(--assistant-border); } +.chat-body .chat-bubble { max-width: clamp(240px, 74%, 900px); } +.chat-bubble p { margin: 0 0 0.6em 0; } +.chat-bubble p:last-child { margin-bottom: 0; } +.chat-message.user .chat-bubble { background: var(--user-border); color: #ffffff; } +.chat-message.user .chat-bubble a { color: #ffffff; text-decoration: underline; } +.chat-message.user .chat-bubble code { background: rgba(255,255,255,0.2); } +.chat-message.user .chat-bubble pre { background: rgba(0,0,0,0.35); } +.chat-meta { font-size: 0.75rem; color: var(--text-muted); margin-top: 2px; } +.chat-meta .timestamp-link { font-size: 0.75rem; } +.chat-meta-extra { margin-left: 4px; } +.chat-group-empty { font-size: 0.85rem; color: var(--text-muted); font-style: italic; } + /* Help dialog */ .kb-help { width: min(720px, 95vw); border: none; border-radius: 12px; padding: 0; box-shadow: 0 12px 40px rgba(0,0,0,0.25); background: var(--card-bg); color: var(--text-color); } .kb-help::backdrop { background: var(--modal-backdrop); } diff --git a/src/codex_transcripts/templates/base.html b/src/codex_transcripts/templates/base.html index 1cde0b1..7d74b44 100644 --- a/src/codex_transcripts/templates/base.html +++ b/src/codex_transcripts/templates/base.html @@ -16,7 +16,7 @@ - +
{%- block content %}{% endblock %}
diff --git a/src/codex_transcripts/templates/chat.html b/src/codex_transcripts/templates/chat.html new file mode 100644 index 0000000..746aeeb --- /dev/null +++ b/src/codex_transcripts/templates/chat.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} + +{% block title %}Codex transcript - Chat{% endblock %} + +{% block body_class %}chat-body{% endblock %} + +{% block content %} +
+

Codex transcript (chat)

+
+ {% include "theme_toggle.html" %} +
+
+ +

{{ total_groups }} conversations - {{ total_messages }} messages{% if task_time_summary %} - {{ task_time_summary }}{% endif %}

+ +
+
+ {% for g in chat_groups %} +
+
+ {{ g.display_label }} + + {{ g.duration_label }} +
+
+ {% if g.messages_html %} + {% for msg_html in g.messages_html %} + {{ msg_html | safe }} + {% endfor %} + {% else %} +
(no user/assistant messages)
+ {% endif %} +
+
+ {% endfor %} +
+
+{%- endblock %} diff --git a/src/codex_transcripts/templates/macros.html b/src/codex_transcripts/templates/macros.html index 0a7d50e..9d67417 100644 --- a/src/codex_transcripts/templates/macros.html +++ b/src/codex_transcripts/templates/macros.html @@ -178,6 +178,11 @@
{{ role_label }}
{{ content_html|safe }}
{%- endmacro %} +{# Chat bubble wrapper - content_html is pre-rendered so needs |safe #} +{% macro chat_message(role_class, msg_id, timestamp, content_html, meta_text) %} +
{{ content_html|safe }}
{% if meta_text %} ยท {{ meta_text }}{% endif %}
+{%- endmacro %} + {# Continuation wrapper - content_html is pre-rendered so needs |safe #} {% macro continuation(content_html) %}
Session continuation summary{{ content_html|safe }}
diff --git a/src/codex_transcripts/transcript.py b/src/codex_transcripts/transcript.py index fffa0ca..3350a4e 100644 --- a/src/codex_transcripts/transcript.py +++ b/src/codex_transcripts/transcript.py @@ -15,6 +15,7 @@ format_tool_stats, get_template, make_msg_id, + render_chat_message, render_markdown_text, render_message, ) @@ -147,21 +148,14 @@ def _format_duration_ms(ms: int | None) -> str: return f"{hours}h {mins_rem:02d}m" -def generate_html_from_session_data( +def _collect_transcript_items( session_data: dict[str, Any], - output_dir: str | Path, *, github_repo: str | None, - stats: ParseStats | None = None, -) -> None: - output_dir = Path(output_dir) - output_dir.mkdir(parents=True, exist_ok=True) - + collect_html: bool = True, +) -> tuple[list[str], list[str], list[str], list[str], list[tuple[str, str, str]]]: loglines = session_data.get("loglines", []) - warnings_html = _format_drift_warning_html(stats) - # Render all transcript messages into lazy-loaded chunks, then build a threaded/foldable view - # that loads conversation bodies on demand. transcript_items_html: list[str] = [] transcript_item_ids: list[str] = [] transcript_item_kinds: list[str] = [] @@ -182,18 +176,26 @@ def generate_html_from_session_data( if not msg_html: continue - transcript_items_html.append(msg_html) + if collect_html: + transcript_items_html.append(msg_html) transcript_item_ids.append(make_msg_id(timestamp)) transcript_item_kinds.append(_classify_message_kind(log_type, message_data)) transcript_item_timestamps.append(timestamp) transcript_item_messages.append((log_type, message_json, timestamp)) - chunk_paths = _write_transcript_chunks( - output_dir=output_dir, - items_html=transcript_items_html, - chunk_size=TRANSCRIPT_CHUNK_SIZE, + return ( + transcript_items_html, + transcript_item_ids, + transcript_item_kinds, + transcript_item_timestamps, + transcript_item_messages, ) + +def _build_conversation_groups( + transcript_item_messages: list[tuple[str, str, str]], + transcript_item_timestamps: list[str], +) -> tuple[list[dict[str, Any]], list[dict[str, Any]], str]: # Conversation groups: split on user prompts, but include any leading system events as the # first group ("session start"). groups: list[dict[str, Any]] = [] @@ -325,6 +327,39 @@ def generate_html_from_session_data( f"max {_format_duration_ms(max(task_duration_ms))}" ) + return groups, rendered_groups, task_time_summary + + +def generate_html_from_session_data( + session_data: dict[str, Any], + output_dir: str | Path, + *, + github_repo: str | None, + stats: ParseStats | None = None, +) -> None: + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + warnings_html = _format_drift_warning_html(stats) + + ( + transcript_items_html, + transcript_item_ids, + transcript_item_kinds, + transcript_item_timestamps, + transcript_item_messages, + ) = _collect_transcript_items(session_data, github_repo=github_repo, collect_html=True) + + chunk_paths = _write_transcript_chunks( + output_dir=output_dir, + items_html=transcript_items_html, + chunk_size=TRANSCRIPT_CHUNK_SIZE, + ) + + _, rendered_groups, task_time_summary = _build_conversation_groups( + transcript_item_messages, transcript_item_timestamps + ) + kind_to_char = {"user": "u", "assistant": "a", "tool_call": "t", "tool_reply": "r", "system": "s"} kinds_compact = "".join(kind_to_char.get(k, "s") for k in transcript_item_kinds) @@ -353,12 +388,72 @@ def generate_html_from_session_data( (output_dir / "index.html").write_text(index_content, encoding="utf-8") +def generate_chat_html_from_session_data( + session_data: dict[str, Any], + output_dir: str | Path, + *, + github_repo: str | None, +) -> None: + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + _, _, _, transcript_item_timestamps, transcript_item_messages = _collect_transcript_items( + session_data, github_repo=github_repo, collect_html=False + ) + + groups, rendered_groups, task_time_summary = _build_conversation_groups( + transcript_item_messages, transcript_item_timestamps + ) + + chat_groups: list[dict[str, Any]] = [] + total_chat_messages = 0 + for idx, group in enumerate(groups): + rendered = rendered_groups[idx] + messages_html: list[str] = [] + meta_text = None + meta_parts: list[str] = [] + tool_stats = rendered.get("tool_stats") + if isinstance(tool_stats, str) and tool_stats.strip(): + meta_parts.append(tool_stats.strip()) + duration_label = rendered.get("duration_label") + if isinstance(duration_label, str) and duration_label.strip() and duration_label != "-": + meta_parts.append(duration_label.strip()) + if meta_parts: + meta_text = " - ".join(meta_parts) + for log_type, message_json, timestamp in group["messages"]: + msg_html = render_chat_message( + log_type, + message_json, + timestamp, + github_repo, + meta_text=meta_text if log_type == "assistant" else None, + ) + if msg_html: + messages_html.append(msg_html) + total_chat_messages += len(messages_html) + payload = dict(rendered) + payload["messages_html"] = messages_html + chat_groups.append(payload) + + template = get_template("chat.html") + html = template.render( + css=CSS, + js=JS, + chat_groups=chat_groups, + total_messages=total_chat_messages, + total_groups=len(rendered_groups), + task_time_summary=task_time_summary, + ) + (output_dir / "index.html").write_text(html, encoding="utf-8") + + def generate_html_from_rollout( rollout_path: str | Path, output_dir: str | Path, *, github_repo: str | None = None, include_json: bool = False, + style: str = "viewer", ) -> tuple[Path, SessionMeta | None, ParseStats]: session_data, meta, stats = parse_rollout_file( rollout_path, @@ -379,7 +474,13 @@ def generate_html_from_rollout( github_repo = detect_github_repo_from_url(meta.git.get("repository_url")) - generate_html_from_session_data(session_data, out_dir, github_repo=github_repo, stats=stats) + if style not in {"viewer", "chat"}: + raise ValueError(f"Unknown render style: {style}") + + if style == "chat": + generate_chat_html_from_session_data(session_data, out_dir, github_repo=github_repo) + else: + generate_html_from_session_data(session_data, out_dir, github_repo=github_repo, stats=stats) return out_dir, meta, stats diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index 7cbd241..3cc6220 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -82,3 +82,20 @@ def test_generate_html_includes_format_drift_warning_for_unknown_response_item_t chunk_js = (out_dir / "chunks" / "chunk-000.js").read_text(encoding="utf-8") assert "system-record" in chunk_js assert "response_item:mystery_item" in chunk_js + + +def test_generate_chat_html_uses_bubbles_and_no_minimap(tmp_path: Path): + rollout = Path(__file__).parent / "sample_rollout.jsonl" + out_dir, _meta, _stats = generate_html_from_rollout( + rollout, + tmp_path / "out", + style="chat", + ) + + index_html = (out_dir / "index.html").read_text(encoding="utf-8") + assert 'class="chat-message user"' in index_html + assert 'class="chat-message assistant"' in index_html + assert "chat-index-message" not in index_html + assert 'id="minimap"' not in index_html + + assert not (out_dir / "chunks").exists()