codex-transcript-viewer 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codex_transcript_viewer/__init__.py +0 -0
- codex_transcript_viewer/cli.py +81 -0
- codex_transcript_viewer/formatting.py +23 -0
- codex_transcript_viewer/html_builder.py +769 -0
- codex_transcript_viewer/markdown.py +156 -0
- codex_transcript_viewer/parser.py +1204 -0
- codex_transcript_viewer/style.css +589 -0
- codex_transcript_viewer/viewer.js +63 -0
- codex_transcript_viewer-0.4.0.dist-info/METADATA +122 -0
- codex_transcript_viewer-0.4.0.dist-info/RECORD +13 -0
- codex_transcript_viewer-0.4.0.dist-info/WHEEL +4 -0
- codex_transcript_viewer-0.4.0.dist-info/entry_points.txt +2 -0
- codex_transcript_viewer-0.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
"""Build a self-contained HTML viewer from parsed Codex session events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from importlib import resources
|
|
10
|
+
|
|
11
|
+
from .formatting import format_ts, format_ts_full
|
|
12
|
+
from .markdown import escape, render_markdown
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _load_asset(name: str) -> str:
|
|
16
|
+
"""Load a bundled CSS or JS asset from the package."""
|
|
17
|
+
return resources.files(__package__).joinpath(name).read_text(encoding="utf-8")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
DEFAULT_IMAGE_BUDGET_MB = 25
|
|
21
|
+
# Codex already limits model-visible tool output to roughly 200K characters, so
|
|
22
|
+
# this only guards against pathological inputs; it never hides what the model saw.
|
|
23
|
+
DEFAULT_MAX_OUTPUT_CHARS = 250_000
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class RenderContext:
|
|
28
|
+
"""Options and running state shared by the per-event renderers."""
|
|
29
|
+
|
|
30
|
+
embed_images: bool = True
|
|
31
|
+
# Budget for tool-output images, in characters of embedded data URL.
|
|
32
|
+
# Prompt images are always embedded and do not count against it.
|
|
33
|
+
image_budget: int = DEFAULT_IMAGE_BUDGET_MB * 1_000_000
|
|
34
|
+
max_output_chars: int = DEFAULT_MAX_OUTPUT_CHARS
|
|
35
|
+
image_used: int = 0
|
|
36
|
+
images_omitted: int = 0
|
|
37
|
+
images_omitted_bytes: int = 0
|
|
38
|
+
outputs_by_call: dict = field(default_factory=dict)
|
|
39
|
+
names_by_call: dict = field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_html(
|
|
43
|
+
meta: dict | None,
|
|
44
|
+
events: list[dict],
|
|
45
|
+
*,
|
|
46
|
+
embed_images: bool = True,
|
|
47
|
+
max_image_mb: float = DEFAULT_IMAGE_BUDGET_MB,
|
|
48
|
+
max_output_chars: int = DEFAULT_MAX_OUTPUT_CHARS,
|
|
49
|
+
) -> str:
|
|
50
|
+
"""Build a self-contained HTML string from session metadata and events.
|
|
51
|
+
|
|
52
|
+
``max_image_mb`` caps embedded tool-output images per page and
|
|
53
|
+
``max_output_chars`` caps each tool output's text; 0 disables either cap.
|
|
54
|
+
"""
|
|
55
|
+
ctx = RenderContext(
|
|
56
|
+
embed_images=embed_images,
|
|
57
|
+
image_budget=int(max_image_mb * 1_000_000),
|
|
58
|
+
max_output_chars=max_output_chars,
|
|
59
|
+
)
|
|
60
|
+
for evt in events:
|
|
61
|
+
call_id = evt.get("call_id")
|
|
62
|
+
if not call_id:
|
|
63
|
+
continue
|
|
64
|
+
if evt["type"] == "tool_output":
|
|
65
|
+
ctx.outputs_by_call.setdefault(call_id, evt)
|
|
66
|
+
elif evt["type"] == "tool_call":
|
|
67
|
+
ctx.names_by_call.setdefault(call_id, evt.get("name", ""))
|
|
68
|
+
session_id = meta.get("id", "unknown") if meta else "unknown"
|
|
69
|
+
model = meta.get("model_provider", "") if meta else ""
|
|
70
|
+
cli_version = meta.get("cli_version", "") if meta else ""
|
|
71
|
+
cwd = meta.get("cwd", "") if meta else ""
|
|
72
|
+
branch = meta.get("git", {}).get("branch", "") if meta else ""
|
|
73
|
+
commit = (meta.get("git", {}).get("commit_hash", "") or "")[:12] if meta else ""
|
|
74
|
+
session_ts = meta.get("timestamp", "") if meta else ""
|
|
75
|
+
|
|
76
|
+
sidebar_items: list[str] = []
|
|
77
|
+
message_blocks: list[str] = []
|
|
78
|
+
msg_idx = 0
|
|
79
|
+
|
|
80
|
+
inherited_run = 0
|
|
81
|
+
for i, evt in enumerate(events):
|
|
82
|
+
etype = evt["type"]
|
|
83
|
+
ts = format_ts(evt["ts"])
|
|
84
|
+
msg_idx += 1
|
|
85
|
+
anchor = f"msg-{msg_idx}"
|
|
86
|
+
|
|
87
|
+
inherited = bool(evt.get("inherited"))
|
|
88
|
+
if inherited and not (i and events[i - 1].get("inherited")):
|
|
89
|
+
inherited_run += 1
|
|
90
|
+
run_length = _inherited_run_length(events, i)
|
|
91
|
+
run_anchor = f"inherited-{inherited_run}"
|
|
92
|
+
sidebar_items.append(
|
|
93
|
+
f'<a class="tree-node tree-role-inherited" data-kind="inherited" href="#{run_anchor}">'
|
|
94
|
+
f'<span class="tree-ts">{ts}</span> '
|
|
95
|
+
f'<span class="tree-content">\u21aa Inherited parent history ({run_length})</span></a>'
|
|
96
|
+
)
|
|
97
|
+
message_blocks.append(
|
|
98
|
+
f'<details class="inherited-history" id="{run_anchor}">'
|
|
99
|
+
f"<summary>Inherited from the parent session \u00b7 {run_length} entries</summary>"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
handler = _EVENT_HANDLERS.get(etype)
|
|
103
|
+
if handler:
|
|
104
|
+
# Inherited entries stay out of the sidebar; one node links the block.
|
|
105
|
+
handler(evt, ts, anchor, [] if inherited else sidebar_items, message_blocks, ctx)
|
|
106
|
+
|
|
107
|
+
if inherited and not (i + 1 < len(events) and events[i + 1].get("inherited")):
|
|
108
|
+
message_blocks.append("</details>")
|
|
109
|
+
|
|
110
|
+
css = _load_asset("style.css")
|
|
111
|
+
js = _load_asset("viewer.js")
|
|
112
|
+
|
|
113
|
+
sidebar_html = "\n".join(sidebar_items)
|
|
114
|
+
messages_html = "\n".join(message_blocks)
|
|
115
|
+
generated = datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
116
|
+
|
|
117
|
+
return _HTML_TEMPLATE.format(
|
|
118
|
+
title=escape(session_id[:12]),
|
|
119
|
+
css=css,
|
|
120
|
+
js=js,
|
|
121
|
+
sidebar_html=sidebar_html,
|
|
122
|
+
messages_html=messages_html,
|
|
123
|
+
session_id_short=escape(session_id[:12]),
|
|
124
|
+
session_ts_short=escape(format_ts_full(session_ts)),
|
|
125
|
+
session_id=escape(session_id),
|
|
126
|
+
session_ts=escape(format_ts_full(session_ts)),
|
|
127
|
+
model=escape(model),
|
|
128
|
+
cli_version=escape(cli_version),
|
|
129
|
+
cwd=escape(cwd),
|
|
130
|
+
git_info=escape(branch) + ((" @ " + escape(commit)) if commit else ""),
|
|
131
|
+
subagent_info=_subagent_info_html(meta, events),
|
|
132
|
+
image_notice=_image_notice_html(ctx),
|
|
133
|
+
generated=generated,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _subagent_info_html(meta: dict | None, events: list[dict]) -> str:
|
|
138
|
+
"""Header rows that identify a subagent thread and its parent."""
|
|
139
|
+
source = meta.get("source") if isinstance(meta, dict) else None
|
|
140
|
+
if not isinstance(source, dict) or "subagent" not in source:
|
|
141
|
+
return ""
|
|
142
|
+
sub = source["subagent"]
|
|
143
|
+
spawn = sub.get("thread_spawn") if isinstance(sub, dict) else None
|
|
144
|
+
if isinstance(spawn, dict):
|
|
145
|
+
name = spawn.get("agent_nickname") or "subagent"
|
|
146
|
+
details = [str(spawn.get(k)) for k in ("agent_path", "agent_role") if spawn.get(k)]
|
|
147
|
+
if spawn.get("depth") is not None:
|
|
148
|
+
details.append(f"depth {spawn['depth']}")
|
|
149
|
+
label = name + (f" ({', '.join(details)})" if details else "")
|
|
150
|
+
parent = spawn.get("parent_thread_id") or ""
|
|
151
|
+
else:
|
|
152
|
+
kind = sub.get("other") if isinstance(sub, dict) else sub
|
|
153
|
+
label = str(kind or "subagent")
|
|
154
|
+
parent = ""
|
|
155
|
+
rows = [
|
|
156
|
+
'<div class="info-item"><span class="info-label">Subagent</span>'
|
|
157
|
+
f'<span class="info-value">{escape(label)}</span></div>'
|
|
158
|
+
]
|
|
159
|
+
if parent:
|
|
160
|
+
rows.append(
|
|
161
|
+
'<div class="info-item"><span class="info-label">Parent Thread</span>'
|
|
162
|
+
f'<span class="info-value">{escape(parent)}</span></div>'
|
|
163
|
+
)
|
|
164
|
+
if any(evt.get("inherited") for evt in events):
|
|
165
|
+
rows.append(
|
|
166
|
+
'<div class="info-item"><span class="info-label">History</span>'
|
|
167
|
+
'<span class="info-value">Forked from the parent; its earlier turns are '
|
|
168
|
+
"collapsed below</span></div>"
|
|
169
|
+
)
|
|
170
|
+
return "\n " + "\n ".join(rows)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _image_notice_html(ctx: RenderContext) -> str:
|
|
174
|
+
if not ctx.images_omitted:
|
|
175
|
+
return ""
|
|
176
|
+
count = ctx.images_omitted
|
|
177
|
+
noun = "tool image" if count == 1 else "tool images"
|
|
178
|
+
size = _format_bytes(ctx.images_omitted_bytes) if ctx.images_omitted_bytes else ""
|
|
179
|
+
detail = f" ({size})" if size else ""
|
|
180
|
+
return (
|
|
181
|
+
f'<div class="image-notice">{count} {noun}{detail} not embedded to keep this page '
|
|
182
|
+
f"under {ctx.image_budget // 1_000_000} MB of images; rerun with "
|
|
183
|
+
"<code>--max-image-mb 0</code> to include them.</div>"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _inherited_run_length(events: list[dict], start: int) -> int:
|
|
188
|
+
end = start
|
|
189
|
+
while end < len(events) and events[end].get("inherited"):
|
|
190
|
+
end += 1
|
|
191
|
+
return end - start
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
# Per-event-type rendering functions
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
_DATA_URL_RE = re.compile(r"^data:image/(png|jpeg|jpg|gif|webp);base64,[A-Za-z0-9+/=]+$")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _basename(path: str) -> str:
|
|
202
|
+
return path.replace("\\", "/").rstrip("/").rsplit("/", 1)[-1]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _format_bytes(n: int) -> str:
|
|
206
|
+
if n >= 1_000_000:
|
|
207
|
+
return f"{n / 1_000_000:.1f} MB"
|
|
208
|
+
return f"{max(1, round(n / 1000))} KB"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _image_label(attachment: dict, default: str) -> str:
|
|
212
|
+
if attachment.get("kind") == "local_image" and attachment.get("path"):
|
|
213
|
+
return _basename(attachment["path"])
|
|
214
|
+
return attachment.get("label") or default
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _within_image_budget(url: str, ctx: RenderContext) -> bool:
|
|
218
|
+
if ctx.image_budget <= 0 or ctx.image_used + len(url) <= ctx.image_budget:
|
|
219
|
+
ctx.image_used += len(url)
|
|
220
|
+
return True
|
|
221
|
+
return False
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _render_image(
|
|
225
|
+
attachment: dict,
|
|
226
|
+
ctx: RenderContext,
|
|
227
|
+
*,
|
|
228
|
+
embed: bool,
|
|
229
|
+
default_label: str,
|
|
230
|
+
budgeted: bool = False,
|
|
231
|
+
) -> str:
|
|
232
|
+
"""Render an image attachment as a thumbnail, or as a chip when not embedded."""
|
|
233
|
+
label = _image_label(attachment, default_label)
|
|
234
|
+
url = attachment.get("data_url", "")
|
|
235
|
+
size = attachment.get("bytes")
|
|
236
|
+
embeddable = embed and ctx.embed_images and isinstance(url, str) and bool(_DATA_URL_RE.match(url))
|
|
237
|
+
if embeddable and budgeted and not _within_image_budget(url, ctx):
|
|
238
|
+
ctx.images_omitted += 1
|
|
239
|
+
ctx.images_omitted_bytes += size if isinstance(size, int) else 0
|
|
240
|
+
embeddable = False
|
|
241
|
+
if embeddable:
|
|
242
|
+
return (
|
|
243
|
+
'<figure class="attachment-image">'
|
|
244
|
+
f'<img src="{url}" alt="{escape(label)}" loading="lazy" decoding="async" '
|
|
245
|
+
"onclick=\"this.classList.toggle('full')\">"
|
|
246
|
+
f"<figcaption>{escape(label)}</figcaption></figure>"
|
|
247
|
+
)
|
|
248
|
+
detail = f", {_format_bytes(size)}" if isinstance(size, int) and size > 0 else ""
|
|
249
|
+
return f'<span class="attachment-chip">[image: {escape(label)}{detail}]</span>'
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _render_attachments(
|
|
253
|
+
attachments: list[dict],
|
|
254
|
+
ctx: RenderContext,
|
|
255
|
+
*,
|
|
256
|
+
embed: bool = True,
|
|
257
|
+
default_label: str = "pasted image",
|
|
258
|
+
budgeted: bool = False,
|
|
259
|
+
) -> str:
|
|
260
|
+
parts = []
|
|
261
|
+
for attachment in attachments:
|
|
262
|
+
kind = attachment.get("kind")
|
|
263
|
+
if kind in ("local_image", "image"):
|
|
264
|
+
parts.append(
|
|
265
|
+
_render_image(
|
|
266
|
+
attachment, ctx, embed=embed, default_label=default_label, budgeted=budgeted
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
elif kind == "skill":
|
|
270
|
+
parts.append(f'<span class="attachment-chip">[${escape(attachment.get("name"))}]</span>')
|
|
271
|
+
elif kind == "mention":
|
|
272
|
+
parts.append(f'<span class="attachment-chip">[@{escape(attachment.get("name"))}]</span>')
|
|
273
|
+
if not parts:
|
|
274
|
+
return ""
|
|
275
|
+
return f'<div class="attachments">{"".join(parts)}</div>'
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _render_user_message(evt, ts, anchor, sidebar, messages, ctx):
|
|
279
|
+
attachments = evt.get("attachments") or []
|
|
280
|
+
text = evt["text"]
|
|
281
|
+
text_preview = text[:80].replace("\n", " ")
|
|
282
|
+
if not text_preview and attachments:
|
|
283
|
+
text_preview = "[attachment only]"
|
|
284
|
+
sidebar.append(
|
|
285
|
+
f'<a class="tree-node tree-role-user" data-kind="user" href="#{anchor}">'
|
|
286
|
+
f'<span class="tree-ts">{ts}</span> '
|
|
287
|
+
f'<span class="tree-content">\U0001f464 {escape(text_preview)}</span></a>'
|
|
288
|
+
)
|
|
289
|
+
body = (
|
|
290
|
+
f'<div class="markdown-content">{render_markdown(text)}</div>'
|
|
291
|
+
if text
|
|
292
|
+
else '<div class="attachment-note">(attachment only)</div>'
|
|
293
|
+
)
|
|
294
|
+
messages.append(
|
|
295
|
+
f'<div class="user-message" id="{anchor}">'
|
|
296
|
+
f'<div class="message-timestamp">{ts}</div>'
|
|
297
|
+
f"{body}"
|
|
298
|
+
f"{_render_attachments(attachments, ctx)}"
|
|
299
|
+
f"</div>"
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _render_reasoning(evt, ts, anchor, sidebar, messages, ctx):
|
|
304
|
+
sidebar.append(
|
|
305
|
+
f'<a class="tree-node tree-role-thinking" href="#{anchor}">'
|
|
306
|
+
f'<span class="tree-ts">{ts}</span> '
|
|
307
|
+
f'<span class="tree-content">\U0001f4ad {escape(evt["text"][:60])}</span></a>'
|
|
308
|
+
)
|
|
309
|
+
messages.append(
|
|
310
|
+
f'<div class="thinking-block" id="{anchor}">'
|
|
311
|
+
f'<div class="message-timestamp">{ts}</div>'
|
|
312
|
+
f'<div class="thinking-text">{escape(evt["text"])}</div>'
|
|
313
|
+
f"</div>"
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _render_agent_commentary(evt, ts, anchor, sidebar, messages, ctx):
|
|
318
|
+
sidebar.append(
|
|
319
|
+
f'<a class="tree-node tree-role-assistant" href="#{anchor}">'
|
|
320
|
+
f'<span class="tree-ts">{ts}</span> '
|
|
321
|
+
f'<span class="tree-content">\U0001f4ac {escape(evt["text"][:60])}</span></a>'
|
|
322
|
+
)
|
|
323
|
+
messages.append(
|
|
324
|
+
f'<div class="commentary-message" id="{anchor}">'
|
|
325
|
+
f'<div class="message-timestamp">{ts}</div>'
|
|
326
|
+
f'<div class="markdown-content">{render_markdown(evt["text"])}</div>'
|
|
327
|
+
f"</div>"
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _render_assistant_text(evt, ts, anchor, sidebar, messages, ctx):
|
|
332
|
+
if evt.get("phase") == "final_answer":
|
|
333
|
+
_render_task_complete(evt, ts, anchor, sidebar, messages, ctx)
|
|
334
|
+
return
|
|
335
|
+
phase_label = f' ({evt["phase"]})' if evt.get("phase") else ""
|
|
336
|
+
preview = evt["text"][:60].replace("\n", " ")
|
|
337
|
+
sidebar.append(
|
|
338
|
+
f'<a class="tree-node tree-role-assistant" data-kind="assistant" href="#{anchor}">'
|
|
339
|
+
f'<span class="tree-ts">{ts}</span> '
|
|
340
|
+
f'<span class="tree-content">\U0001f916 {escape(preview)}</span></a>'
|
|
341
|
+
)
|
|
342
|
+
messages.append(
|
|
343
|
+
f'<div class="assistant-message" id="{anchor}">'
|
|
344
|
+
f'<div class="message-timestamp">{ts}{escape(phase_label)}</div>'
|
|
345
|
+
f'<div class="assistant-text markdown-content">{render_markdown(evt["text"])}</div>'
|
|
346
|
+
f"</div>"
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _compact_json(value):
|
|
351
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _format_param_value(value, key=""):
|
|
355
|
+
if key == "cmd" and isinstance(value, str):
|
|
356
|
+
return f'<span class="tool-command">$ {escape(value)}</span>'
|
|
357
|
+
if isinstance(value, str):
|
|
358
|
+
return f"<pre>{escape(value)}</pre>"
|
|
359
|
+
return f"<pre>{escape(json.dumps(value, ensure_ascii=False, indent=2))}</pre>"
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _format_tool_args(arguments):
|
|
363
|
+
try:
|
|
364
|
+
args_obj = json.loads(arguments)
|
|
365
|
+
except (json.JSONDecodeError, TypeError):
|
|
366
|
+
return f"<pre>{escape(arguments)}</pre>"
|
|
367
|
+
|
|
368
|
+
if not isinstance(args_obj, dict):
|
|
369
|
+
return f"<pre>{escape(json.dumps(args_obj, ensure_ascii=False, indent=2))}</pre>"
|
|
370
|
+
|
|
371
|
+
rows = []
|
|
372
|
+
for key, value in args_obj.items():
|
|
373
|
+
rows.append(
|
|
374
|
+
f'<div class="tool-param">'
|
|
375
|
+
f'<div class="tool-param-name">{escape(key)}</div>'
|
|
376
|
+
f'<div class="tool-param-value">{_format_param_value(value, key)}</div>'
|
|
377
|
+
f"</div>"
|
|
378
|
+
)
|
|
379
|
+
return "".join(rows)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
_CODE_MODE_CALL_RE = re.compile(r"\btools\.[A-Za-z_][A-Za-z0-9_]*\(")
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _custom_input_preview(name: str, text: str) -> str:
|
|
386
|
+
"""Sidebar summary for free-form tool input (code-mode exec, apply_patch)."""
|
|
387
|
+
calls = len(_CODE_MODE_CALL_RE.findall(text))
|
|
388
|
+
if calls > 1:
|
|
389
|
+
return f"{calls} tool calls"
|
|
390
|
+
first = next((line.strip() for line in text.splitlines() if line.strip()), "")
|
|
391
|
+
return first[:120]
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _tool_status(output: dict) -> str:
|
|
395
|
+
"""success, failure or unknown; unknown is never presented as success."""
|
|
396
|
+
codes = output.get("exit_codes") or []
|
|
397
|
+
if output.get("failed") is True or any(code != 0 for code in codes):
|
|
398
|
+
return "failure"
|
|
399
|
+
if output.get("failed") is False or codes:
|
|
400
|
+
return "success"
|
|
401
|
+
return "unknown"
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _status_badge(output: dict, status: str) -> str:
|
|
405
|
+
codes = output.get("exit_codes") or []
|
|
406
|
+
if len(codes) > 1:
|
|
407
|
+
failed = sum(1 for code in codes if code != 0)
|
|
408
|
+
text = f"{len(codes)} commands, {failed} failed"
|
|
409
|
+
elif codes:
|
|
410
|
+
text = f"exit {codes[0]}"
|
|
411
|
+
elif status == "failure":
|
|
412
|
+
text = "failed"
|
|
413
|
+
elif status == "success":
|
|
414
|
+
text = "ok"
|
|
415
|
+
else:
|
|
416
|
+
text = "status unknown"
|
|
417
|
+
return f'<span class="tool-status">{text}</span>'
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _render_tool_call(evt, ts, anchor, sidebar, messages, ctx):
|
|
421
|
+
name = evt["name"]
|
|
422
|
+
custom = evt.get("input_kind") in ("custom", "web_search", "tool_search", "image_generation")
|
|
423
|
+
if custom:
|
|
424
|
+
args_preview = _custom_input_preview(name, evt["arguments"])
|
|
425
|
+
else:
|
|
426
|
+
try:
|
|
427
|
+
args = json.loads(evt["arguments"])
|
|
428
|
+
args_preview = _compact_json(args)[:120]
|
|
429
|
+
except (json.JSONDecodeError, TypeError):
|
|
430
|
+
args_preview = evt["arguments"][:120]
|
|
431
|
+
|
|
432
|
+
sidebar.append(
|
|
433
|
+
f'<a class="tree-node tree-role-tool" href="#{anchor}">'
|
|
434
|
+
f'<span class="tree-ts">{ts}</span> '
|
|
435
|
+
f'<span class="tree-content">\u26a1 {escape(name)}: {escape(args_preview)}</span></a>'
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
# Custom tool input is raw text (JavaScript, patches), not JSON parameters.
|
|
439
|
+
args_html = (
|
|
440
|
+
f"<pre>{escape(evt['arguments'])}</pre>"
|
|
441
|
+
if custom
|
|
442
|
+
else _format_tool_args(evt["arguments"])
|
|
443
|
+
)
|
|
444
|
+
call_id = evt.get("call_id")
|
|
445
|
+
if not call_id:
|
|
446
|
+
# Single-record calls (web search) have no separate result to report.
|
|
447
|
+
status, badge = "single", ""
|
|
448
|
+
elif call_id in ctx.outputs_by_call:
|
|
449
|
+
status = _tool_status(ctx.outputs_by_call[call_id])
|
|
450
|
+
badge = _status_badge(ctx.outputs_by_call[call_id], status)
|
|
451
|
+
else:
|
|
452
|
+
status, badge = "no-result", '<span class="tool-status">no result recorded</span>'
|
|
453
|
+
messages.append(
|
|
454
|
+
f'<div class="tool-execution {status}" id="{anchor}">'
|
|
455
|
+
f'<div class="message-timestamp">{ts}</div>'
|
|
456
|
+
f'<div class="tool-header"><span class="tool-name">{escape(name)}</span>{badge}</div>'
|
|
457
|
+
f'<div class="tool-args">{args_html}</div>'
|
|
458
|
+
f"</div>"
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _render_tool_output(evt, ts, anchor, sidebar, messages, ctx):
|
|
463
|
+
output = evt["output"]
|
|
464
|
+
omitted = ""
|
|
465
|
+
cap = ctx.max_output_chars
|
|
466
|
+
if cap > 0 and len(output) > cap:
|
|
467
|
+
# Cut the raw text before escaping so no HTML entity is split.
|
|
468
|
+
omitted = f"\n[{len(output) - cap:,} characters omitted]"
|
|
469
|
+
shown = output[:cap]
|
|
470
|
+
else:
|
|
471
|
+
shown = output
|
|
472
|
+
truncated = len(output) > 2000
|
|
473
|
+
preview = shown[:2000]
|
|
474
|
+
|
|
475
|
+
attachments = evt.get("attachments") or []
|
|
476
|
+
size_label = f"{len(output)} chars"
|
|
477
|
+
if attachments:
|
|
478
|
+
count = len(attachments)
|
|
479
|
+
images = f"{count} image" + ("s" if count != 1 else "")
|
|
480
|
+
size_label = f"{size_label}, {images}" if output else images
|
|
481
|
+
status = _tool_status(evt)
|
|
482
|
+
marker = " \u2717" if status == "failure" else ""
|
|
483
|
+
sidebar.append(
|
|
484
|
+
f'<a class="tree-node tree-role-tool" href="#{anchor}">'
|
|
485
|
+
f'<span class="tree-ts">{ts}</span> '
|
|
486
|
+
f'<span class="tree-content">\U0001f4e4 output ({size_label}){marker}</span></a>'
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
expandable_class = " expandable" if truncated else ""
|
|
490
|
+
expand_hint = (
|
|
491
|
+
f'\n<span class="expand-hint">[click to expand {len(output)} chars]</span>'
|
|
492
|
+
if truncated
|
|
493
|
+
else ""
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
output_images = _render_attachments(
|
|
497
|
+
attachments, ctx, default_label="image output", budgeted=True
|
|
498
|
+
)
|
|
499
|
+
call_name = ctx.names_by_call.get(evt.get("call_id") or "")
|
|
500
|
+
label = f"{escape(call_name)} result" if call_name else "orphan output"
|
|
501
|
+
messages.append(
|
|
502
|
+
f'<div class="tool-execution {status}" id="{anchor}">'
|
|
503
|
+
f'<div class="tool-header"><span class="tool-name">{label}</span>'
|
|
504
|
+
f"{_status_badge(evt, status)}</div>"
|
|
505
|
+
f'<div class="tool-output{expandable_class}" onclick="this.classList.toggle(\'expanded\')">'
|
|
506
|
+
f'<div class="output-preview"><pre>{escape(preview)}{expand_hint}</pre></div>'
|
|
507
|
+
f'<div class="output-full"><pre>{escape(shown)}{escape(omitted)}</pre></div>'
|
|
508
|
+
f"</div>{output_images}</div>"
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _render_task_complete(evt, ts, anchor, sidebar, messages, ctx):
|
|
513
|
+
preview = evt["text"][:60].replace("\n", " ")
|
|
514
|
+
sidebar.append(
|
|
515
|
+
f'<a class="tree-node tree-role-assistant" data-kind="final-answer" href="#{anchor}">'
|
|
516
|
+
f'<span class="tree-ts">{ts}</span> '
|
|
517
|
+
f'<span class="tree-content">\u2705 {escape(preview)}</span></a>'
|
|
518
|
+
)
|
|
519
|
+
messages.append(
|
|
520
|
+
f'<div class="assistant-message final-answer" id="{anchor}">'
|
|
521
|
+
f'<div class="message-timestamp">{ts} \u2014 final answer</div>'
|
|
522
|
+
f'<div class="assistant-text markdown-content">{render_markdown(evt["text"])}</div>'
|
|
523
|
+
f"</div>"
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _render_task_started(evt, ts, anchor, sidebar, messages, ctx):
|
|
528
|
+
sidebar.append(
|
|
529
|
+
f'<a class="tree-node tree-role-system" href="#{anchor}">'
|
|
530
|
+
f'<span class="tree-ts">{ts}</span> '
|
|
531
|
+
f'<span class="tree-content">\u25b6 Turn started</span></a>'
|
|
532
|
+
)
|
|
533
|
+
messages.append(
|
|
534
|
+
f'<div class="system-event" id="{anchor}">'
|
|
535
|
+
f'<div class="message-timestamp">{ts}</div>'
|
|
536
|
+
f'<span class="event-label">\u25b6 Turn started</span>'
|
|
537
|
+
f"</div>"
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _render_turn_aborted(evt, ts, anchor, sidebar, messages, ctx):
|
|
542
|
+
reason = escape(evt["reason"])
|
|
543
|
+
sidebar.append(
|
|
544
|
+
f'<a class="tree-node tree-role-error" href="#{anchor}">'
|
|
545
|
+
f'<span class="tree-ts">{ts}</span> '
|
|
546
|
+
f'<span class="tree-content">\u26d4 Turn aborted: {reason}</span></a>'
|
|
547
|
+
)
|
|
548
|
+
messages.append(
|
|
549
|
+
f'<div class="system-event error-event" id="{anchor}">'
|
|
550
|
+
f'<div class="message-timestamp">{ts}</div>'
|
|
551
|
+
f'<span class="event-label error-text">\u26d4 Turn aborted: {reason}</span>'
|
|
552
|
+
f"</div>"
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _render_thread_rolled_back(evt, ts, anchor, sidebar, messages, ctx):
|
|
557
|
+
n = evt["num_turns"]
|
|
558
|
+
sidebar.append(
|
|
559
|
+
f'<a class="tree-node tree-role-system" href="#{anchor}">'
|
|
560
|
+
f'<span class="tree-ts">{ts}</span> '
|
|
561
|
+
f'<span class="tree-content">\u21a9 Rolled back {n} turn(s)</span></a>'
|
|
562
|
+
)
|
|
563
|
+
messages.append(
|
|
564
|
+
f'<div class="system-event" id="{anchor}">'
|
|
565
|
+
f'<div class="message-timestamp">{ts}</div>'
|
|
566
|
+
f'<span class="event-label">\u21a9 Rolled back {n} turn(s)</span>'
|
|
567
|
+
f"</div>"
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _token_total(total: dict, key: str) -> int:
|
|
572
|
+
value = total.get(key)
|
|
573
|
+
return int(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else 0
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _render_token_count(evt, ts, anchor, sidebar, messages, ctx):
|
|
577
|
+
total = evt["total"]
|
|
578
|
+
if _token_total(total, "input_tokens") <= 0:
|
|
579
|
+
return
|
|
580
|
+
tok_str = (
|
|
581
|
+
f"in:{_token_total(total, 'input_tokens'):,} "
|
|
582
|
+
f"out:{_token_total(total, 'output_tokens'):,} "
|
|
583
|
+
f"reasoning:{_token_total(total, 'reasoning_output_tokens'):,}"
|
|
584
|
+
)
|
|
585
|
+
sidebar.append(
|
|
586
|
+
f'<a class="tree-node tree-role-system" href="#{anchor}">'
|
|
587
|
+
f'<span class="tree-ts">{ts}</span> '
|
|
588
|
+
f'<span class="tree-content">\U0001f4ca {tok_str}</span></a>'
|
|
589
|
+
)
|
|
590
|
+
messages.append(
|
|
591
|
+
f'<div class="token-count" id="{anchor}">'
|
|
592
|
+
f'<div class="message-timestamp">{ts}</div>'
|
|
593
|
+
f'<span class="event-label">\U0001f4ca Tokens \u2014 {tok_str}</span>'
|
|
594
|
+
f"</div>"
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _event_row(anchor, ts, sidebar, messages, *, role, kind, label, title=None, body=""):
|
|
599
|
+
"""A notable session event: sidebar row plus a titled block.
|
|
600
|
+
|
|
601
|
+
``label`` is the sidebar text; ``title`` heads the block and defaults to it.
|
|
602
|
+
"""
|
|
603
|
+
kind_attr = f' data-kind="{kind}"' if kind else ""
|
|
604
|
+
sidebar.append(
|
|
605
|
+
f'<a class="tree-node tree-role-{role}"{kind_attr} href="#{anchor}">'
|
|
606
|
+
f'<span class="tree-ts">{ts}</span> '
|
|
607
|
+
f'<span class="tree-content">{escape(label[:120])}</span></a>'
|
|
608
|
+
)
|
|
609
|
+
messages.append(
|
|
610
|
+
f'<div class="session-event {role}-event" id="{anchor}">'
|
|
611
|
+
f'<div class="message-timestamp">{ts}</div>'
|
|
612
|
+
f'<div class="event-title">{escape(label if title is None else title)}</div>{body}</div>'
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
_GOAL_STATUS_LABELS = {
|
|
617
|
+
"active": "Goal resumed",
|
|
618
|
+
"complete": "Goal complete",
|
|
619
|
+
"paused": "Goal paused",
|
|
620
|
+
"blocked": "Goal blocked",
|
|
621
|
+
"budgetLimited": "Goal stopped at its budget",
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _format_duration(seconds) -> str:
|
|
626
|
+
minutes, secs = divmod(int(seconds), 60)
|
|
627
|
+
hours, minutes = divmod(minutes, 60)
|
|
628
|
+
if hours:
|
|
629
|
+
return f"{hours}h {minutes}m"
|
|
630
|
+
return f"{minutes}m {secs}s" if minutes else f"{secs}s"
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _render_goal_updated(evt, ts, anchor, sidebar, messages, ctx):
|
|
634
|
+
objective = evt.get("objective", "")
|
|
635
|
+
status = evt.get("status", "")
|
|
636
|
+
if evt.get("new_objective"):
|
|
637
|
+
title = "\U0001f3af Goal set" if status in ("", "active") else f"\U0001f3af Goal ({status})"
|
|
638
|
+
label = f"{title}: " + objective.replace("\n", " ")
|
|
639
|
+
else:
|
|
640
|
+
label = title = "\U0001f3af " + _GOAL_STATUS_LABELS.get(status, f"Goal {status}")
|
|
641
|
+
usage = []
|
|
642
|
+
if evt.get("tokens_used"):
|
|
643
|
+
usage.append(f"{evt['tokens_used']:,} tokens")
|
|
644
|
+
if evt.get("time_used_seconds"):
|
|
645
|
+
usage.append(_format_duration(evt["time_used_seconds"]))
|
|
646
|
+
body = ""
|
|
647
|
+
if evt.get("new_objective") and objective:
|
|
648
|
+
body += f'<div class="markdown-content">{render_markdown(objective)}</div>'
|
|
649
|
+
if usage:
|
|
650
|
+
body += f'<div class="event-detail">Used so far: {escape(", ".join(usage))}</div>'
|
|
651
|
+
_event_row(anchor, ts, sidebar, messages, role="event", kind="goal", label=label,
|
|
652
|
+
title=title, body=body)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _render_review_started(evt, ts, anchor, sidebar, messages, ctx):
|
|
656
|
+
hint = evt.get("hint") or "code review"
|
|
657
|
+
_event_row(anchor, ts, sidebar, messages, role="event", kind="review",
|
|
658
|
+
label=f"\U0001f50d Review started: {hint}")
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _render_review_finished(evt, ts, anchor, sidebar, messages, ctx):
|
|
662
|
+
findings = evt.get("findings") or []
|
|
663
|
+
count = f"{len(findings)} finding" + ("" if len(findings) == 1 else "s")
|
|
664
|
+
verdict = evt.get("verdict") or "no verdict"
|
|
665
|
+
body = ""
|
|
666
|
+
if evt.get("repeated_by_reply"):
|
|
667
|
+
body = '<div class="event-detail">The full review is in the reply below.</div>'
|
|
668
|
+
elif evt.get("explanation"):
|
|
669
|
+
body += f'<div class="markdown-content">{render_markdown(evt["explanation"])}</div>'
|
|
670
|
+
if findings and not evt.get("repeated_by_reply"):
|
|
671
|
+
items = []
|
|
672
|
+
for finding in findings:
|
|
673
|
+
where = (
|
|
674
|
+
f'<div class="event-detail">{escape(finding["location"])}</div>'
|
|
675
|
+
if finding.get("location") else ""
|
|
676
|
+
)
|
|
677
|
+
items.append(
|
|
678
|
+
f'<li><div class="review-finding-title">{escape(finding.get("title", ""))}</div>'
|
|
679
|
+
f'{where}<div class="markdown-content">{render_markdown(finding.get("body", ""))}</div></li>'
|
|
680
|
+
)
|
|
681
|
+
body += f'<ol class="review-findings">{"".join(items)}</ol>'
|
|
682
|
+
_event_row(anchor, ts, sidebar, messages, role="event", kind="review",
|
|
683
|
+
label=f"\U0001f50d Review done: {verdict}, {count}", body=body)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _render_hook_prompt(evt, ts, anchor, sidebar, messages, ctx):
|
|
687
|
+
hook = f"{evt['hook']} hook" if evt.get("hook") else "Hook"
|
|
688
|
+
preview = evt["text"].replace("\n", " ")
|
|
689
|
+
body = f'<div class="markdown-content">{render_markdown(evt["text"])}</div>'
|
|
690
|
+
_event_row(anchor, ts, sidebar, messages, role="event", kind="hook",
|
|
691
|
+
label=f"\U0001fa9d {hook}: {preview}", title=f"\U0001fa9d {hook}", body=body)
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _render_error(evt, ts, anchor, sidebar, messages, ctx):
|
|
695
|
+
_event_row(anchor, ts, sidebar, messages, role="error", kind="",
|
|
696
|
+
label=f"\u26a0 {evt.get('message') or 'Error'}")
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
_EVENT_HANDLERS = {
|
|
700
|
+
"user_message": _render_user_message,
|
|
701
|
+
"reasoning": _render_reasoning,
|
|
702
|
+
"agent_commentary": _render_agent_commentary,
|
|
703
|
+
"assistant_text": _render_assistant_text,
|
|
704
|
+
"tool_call": _render_tool_call,
|
|
705
|
+
"tool_output": _render_tool_output,
|
|
706
|
+
"task_complete": _render_task_complete,
|
|
707
|
+
"task_started": _render_task_started,
|
|
708
|
+
"turn_aborted": _render_turn_aborted,
|
|
709
|
+
"thread_rolled_back": _render_thread_rolled_back,
|
|
710
|
+
"token_count": _render_token_count,
|
|
711
|
+
"goal_updated": _render_goal_updated,
|
|
712
|
+
"review_started": _render_review_started,
|
|
713
|
+
"review_finished": _render_review_finished,
|
|
714
|
+
"hook_prompt": _render_hook_prompt,
|
|
715
|
+
"error": _render_error,
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
# ---------------------------------------------------------------------------
|
|
720
|
+
# HTML shell template
|
|
721
|
+
# ---------------------------------------------------------------------------
|
|
722
|
+
|
|
723
|
+
_HTML_TEMPLATE = """\
|
|
724
|
+
<!DOCTYPE html>
|
|
725
|
+
<html lang="en">
|
|
726
|
+
<head>
|
|
727
|
+
<meta charset="UTF-8">
|
|
728
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
729
|
+
<title>Codex CLI Session \u2014 {title}</title>
|
|
730
|
+
<style>{css}</style>
|
|
731
|
+
</head>
|
|
732
|
+
<body>
|
|
733
|
+
<button id="hamburger" onclick="document.getElementById('sidebar').classList.toggle('open'); document.getElementById('sidebar-overlay').classList.toggle('open')">\u2630</button>
|
|
734
|
+
<div id="sidebar-overlay" onclick="document.getElementById('sidebar').classList.remove('open'); this.classList.remove('open')"></div>
|
|
735
|
+
<div id="app">
|
|
736
|
+
<aside id="sidebar">
|
|
737
|
+
<div class="sidebar-header">
|
|
738
|
+
<h2>CODEX CLI SESSION</h2>
|
|
739
|
+
<div class="sidebar-meta">{session_id_short} \u00b7 {session_ts_short}</div>
|
|
740
|
+
<input type="text" class="sidebar-search" id="tree-search" placeholder="Filter entries..." oninput="filterTree(this.value)">
|
|
741
|
+
<div class="sidebar-filters">
|
|
742
|
+
<button class="filter-btn active" data-filter="default" onclick="setFilter('default', this)">Default</button>
|
|
743
|
+
<button class="filter-btn" data-filter="no-tools" onclick="setFilter('no-tools', this)">No tools</button>
|
|
744
|
+
<button class="filter-btn" data-filter="user-only" onclick="setFilter('user-only', this)">User</button>
|
|
745
|
+
<button class="filter-btn" data-filter="answers" onclick="setFilter('answers', this)">Answers</button>
|
|
746
|
+
<button class="filter-btn" data-filter="all" onclick="setFilter('all', this)">All</button>
|
|
747
|
+
</div>
|
|
748
|
+
</div>
|
|
749
|
+
<div class="tree-container" id="tree-container">{sidebar_html}</div>
|
|
750
|
+
</aside>
|
|
751
|
+
<main id="content">
|
|
752
|
+
<div class="header">
|
|
753
|
+
<h1><span class="codex-logo">CODEX</span> Session Transcript</h1>
|
|
754
|
+
<div class="header-info">
|
|
755
|
+
<div class="info-item"><span class="info-label">Session ID</span><span class="info-value">{session_id}</span></div>
|
|
756
|
+
<div class="info-item"><span class="info-label">Timestamp</span><span class="info-value">{session_ts}</span></div>
|
|
757
|
+
<div class="info-item"><span class="info-label">Model</span><span class="info-value">{model}</span></div>
|
|
758
|
+
<div class="info-item"><span class="info-label">CLI Version</span><span class="info-value">{cli_version}</span></div>
|
|
759
|
+
<div class="info-item"><span class="info-label">Working Dir</span><span class="info-value">{cwd}</span></div>
|
|
760
|
+
<div class="info-item"><span class="info-label">Git Branch</span><span class="info-value">{git_info}</span></div>{subagent_info}
|
|
761
|
+
</div>
|
|
762
|
+
</div>
|
|
763
|
+
{image_notice}<div id="messages">{messages_html}</div>
|
|
764
|
+
<div class="footer">Codex CLI session transcript \u00b7 Generated {generated}</div>
|
|
765
|
+
</main>
|
|
766
|
+
</div>
|
|
767
|
+
<script>{js}</script>
|
|
768
|
+
</body>
|
|
769
|
+
</html>"""
|