Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
25 changes: 25 additions & 0 deletions src/codex_transcripts/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.")
Expand All @@ -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)
Expand Down Expand Up @@ -222,6 +233,7 @@ def local_cmd(
out_dir,
github_repo=repo,
include_json=include_source,
style=output_style,
)

_print_stats(stats)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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":
Expand All @@ -440,6 +464,7 @@ def json_cmd(
out_dir,
github_repo=repo,
include_json=include_source,
style=output_style,
)
_print_stats(stats)

Expand Down
115 changes: 113 additions & 2 deletions src/codex_transcripts/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """
Expand All @@ -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;
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -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); }
Expand Down
2 changes: 1 addition & 1 deletion src/codex_transcripts/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
</script>
<style>{{ css|safe }}</style>
</head>
<body>
<body class="{% block body_class %}{% endblock %}">
<div class="container">
{%- block content %}{% endblock %}
</div>
Expand Down
39 changes: 39 additions & 0 deletions src/codex_transcripts/templates/chat.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{% extends "base.html" %}

{% block title %}Codex transcript - Chat{% endblock %}

{% block body_class %}chat-body{% endblock %}

{% block content %}
<div class="header-row">
<h1>Codex transcript (chat)</h1>
<div class="header-controls">
{% include "theme_toggle.html" %}
</div>
</div>

<p class="viewer-summary">{{ total_groups }} conversations - {{ total_messages }} messages{% if task_time_summary %} - {{ task_time_summary }}{% endif %}</p>

<div class="chat-view">
<div class="chat-transcript">
{% for g in chat_groups %}
<div class="chat-group" id="group-{{ g.group_index }}">
<div class="chat-group-header">
<span class="chat-group-label">{{ g.display_label }}</span>
<time datetime="{{ g.start_ts }}" data-timestamp="{{ g.start_ts }}">{{ g.start_ts }}</time>
<span class="chat-group-duration">{{ g.duration_label }}</span>
</div>
<div class="chat-messages">
{% if g.messages_html %}
{% for msg_html in g.messages_html %}
{{ msg_html | safe }}
{% endfor %}
{% else %}
<div class="chat-group-empty">(no user/assistant messages)</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
{%- endblock %}
5 changes: 5 additions & 0 deletions src/codex_transcripts/templates/macros.html
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,11 @@
<div class="message {{ role_class }}" id="{{ msg_id }}"><div class="message-header"><span class="role-label">{{ role_label }}</span><a href="#{{ msg_id }}" class="timestamp-link"><time datetime="{{ timestamp }}" data-timestamp="{{ timestamp }}">{{ timestamp }}</time></a></div><div class="message-content">{{ content_html|safe }}</div></div>
{%- endmacro %}

{# Chat bubble wrapper - content_html is pre-rendered so needs |safe #}
{% macro chat_message(role_class, msg_id, timestamp, content_html, meta_text) %}
<div class="chat-message {{ role_class }}" id="{{ msg_id }}"><div class="chat-bubble">{{ content_html|safe }}</div><div class="chat-meta"><a href="#{{ msg_id }}" class="timestamp-link"><time datetime="{{ timestamp }}" data-timestamp="{{ timestamp }}">{{ timestamp }}</time></a>{% if meta_text %}<span class="chat-meta-extra"> · {{ meta_text }}</span>{% endif %}</div></div>
{%- endmacro %}

{# Continuation wrapper - content_html is pre-rendered so needs |safe #}
{% macro continuation(content_html) %}
<details class="continuation"><summary>Session continuation summary</summary>{{ content_html|safe }}</details>
Expand Down
Loading