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 @@ -
+{{ total_groups }} conversations - {{ total_messages }} messages{% if task_time_summary %} - {{ task_time_summary }}{% endif %}
+ +