vtx-coding-agent 0.1.1__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.
Files changed (117) hide show
  1. vtx/__init__.py +63 -0
  2. vtx/async_utils.py +40 -0
  3. vtx/builtin_skills/github/SKILL.md +139 -0
  4. vtx/builtin_skills/init/SKILL.md +74 -0
  5. vtx/builtin_skills/review/SKILL.md +73 -0
  6. vtx/builtin_skills/skill-builder/SKILL.md +133 -0
  7. vtx/cli.py +90 -0
  8. vtx/config.py +741 -0
  9. vtx/context/__init__.py +15 -0
  10. vtx/context/_xml.py +8 -0
  11. vtx/context/agent_mds.py +128 -0
  12. vtx/context/git.py +64 -0
  13. vtx/context/loader.py +41 -0
  14. vtx/context/skills.py +423 -0
  15. vtx/core/__init__.py +47 -0
  16. vtx/core/compaction.py +89 -0
  17. vtx/core/errors.py +17 -0
  18. vtx/core/handoff.py +51 -0
  19. vtx/core/scratchpad.py +54 -0
  20. vtx/core/types.py +197 -0
  21. vtx/defaults/__init__.py +0 -0
  22. vtx/defaults/config.yml +53 -0
  23. vtx/diff_display.py +12 -0
  24. vtx/events.py +224 -0
  25. vtx/gh_cli.py +82 -0
  26. vtx/git_branch.py +90 -0
  27. vtx/headless.py +127 -0
  28. vtx/llm/__init__.py +93 -0
  29. vtx/llm/base.py +217 -0
  30. vtx/llm/context_length.py +150 -0
  31. vtx/llm/dynamic_models.py +735 -0
  32. vtx/llm/model_fetcher.py +279 -0
  33. vtx/llm/models.py +78 -0
  34. vtx/llm/oauth/__init__.py +59 -0
  35. vtx/llm/oauth/copilot.py +358 -0
  36. vtx/llm/oauth/dynamic.py +236 -0
  37. vtx/llm/oauth/openai.py +400 -0
  38. vtx/llm/phase_parser.py +270 -0
  39. vtx/llm/provider.yaml +280 -0
  40. vtx/llm/provider_catalog.py +230 -0
  41. vtx/llm/providers/__init__.py +45 -0
  42. vtx/llm/providers/anthropic_sdk.py +256 -0
  43. vtx/llm/providers/mock.py +249 -0
  44. vtx/llm/providers/openai_sdk.py +246 -0
  45. vtx/llm/providers/sanitize.py +14 -0
  46. vtx/llm/sdk/__init__.py +13 -0
  47. vtx/llm/sdk/anthropic.py +382 -0
  48. vtx/llm/sdk/base.py +82 -0
  49. vtx/llm/sdk/openai.py +344 -0
  50. vtx/llm/tool_parser.py +161 -0
  51. vtx/loop.py +272 -0
  52. vtx/notify.py +109 -0
  53. vtx/permissions.py +114 -0
  54. vtx/prompts/__init__.py +45 -0
  55. vtx/prompts/builder.py +86 -0
  56. vtx/prompts/env.py +58 -0
  57. vtx/prompts/identity.py +166 -0
  58. vtx/prompts/tooling.py +36 -0
  59. vtx/py.typed +0 -0
  60. vtx/runtime.py +580 -0
  61. vtx/session.py +868 -0
  62. vtx/sounds/completion.wav +0 -0
  63. vtx/sounds/error.wav +0 -0
  64. vtx/sounds/permission.wav +0 -0
  65. vtx/themes.py +1104 -0
  66. vtx/tools/__init__.py +68 -0
  67. vtx/tools/_read_image.py +106 -0
  68. vtx/tools/_tool_utils.py +90 -0
  69. vtx/tools/base.py +36 -0
  70. vtx/tools/bash.py +371 -0
  71. vtx/tools/edit.py +261 -0
  72. vtx/tools/find.py +132 -0
  73. vtx/tools/read.py +238 -0
  74. vtx/tools/skill.py +278 -0
  75. vtx/tools/web.py +238 -0
  76. vtx/tools/write.py +88 -0
  77. vtx/tools_manager.py +216 -0
  78. vtx/turn.py +789 -0
  79. vtx/ui/__init__.py +0 -0
  80. vtx/ui/agent_runner.py +417 -0
  81. vtx/ui/app.py +665 -0
  82. vtx/ui/app_protocol.py +29 -0
  83. vtx/ui/autocomplete.py +440 -0
  84. vtx/ui/blocks.py +735 -0
  85. vtx/ui/chat.py +613 -0
  86. vtx/ui/clipboard.py +59 -0
  87. vtx/ui/commands/__init__.py +100 -0
  88. vtx/ui/commands/auth.py +306 -0
  89. vtx/ui/commands/base.py +122 -0
  90. vtx/ui/commands/models.py +144 -0
  91. vtx/ui/commands/sessions.py +388 -0
  92. vtx/ui/commands/settings.py +286 -0
  93. vtx/ui/completion_ui.py +313 -0
  94. vtx/ui/export.py +703 -0
  95. vtx/ui/floating_list.py +370 -0
  96. vtx/ui/formatting.py +287 -0
  97. vtx/ui/input.py +760 -0
  98. vtx/ui/latex.py +349 -0
  99. vtx/ui/launch.py +108 -0
  100. vtx/ui/path_complete.py +228 -0
  101. vtx/ui/prompt_history.py +102 -0
  102. vtx/ui/queue_ui.py +141 -0
  103. vtx/ui/selection_mode.py +18 -0
  104. vtx/ui/session_ui.py +235 -0
  105. vtx/ui/startup.py +124 -0
  106. vtx/ui/styles.py +327 -0
  107. vtx/ui/tool_output.py +34 -0
  108. vtx/ui/tree.py +437 -0
  109. vtx/ui/welcome.py +51 -0
  110. vtx/ui/widgets.py +558 -0
  111. vtx/update_check.py +49 -0
  112. vtx/version.py +22 -0
  113. vtx_coding_agent-0.1.1.dist-info/METADATA +259 -0
  114. vtx_coding_agent-0.1.1.dist-info/RECORD +117 -0
  115. vtx_coding_agent-0.1.1.dist-info/WHEEL +4 -0
  116. vtx_coding_agent-0.1.1.dist-info/entry_points.txt +2 -0
  117. vtx_coding_agent-0.1.1.dist-info/licenses/LICENSE +201 -0
vtx/ui/export.py ADDED
@@ -0,0 +1,703 @@
1
+ """Standalone session HTML export.
2
+
3
+ By design this module parses session JSONL directly and avoids coupling itself to
4
+ other Vtx internals. The only allowed Vtx dependency here is the tool registry,
5
+ used to look up tool descriptions and parameter schemas for rendering.
6
+ """
7
+
8
+ import html
9
+ import json
10
+ import re
11
+ from contextlib import suppress
12
+ from dataclasses import dataclass
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from .. import get_config_dir
18
+ from ..tools import tools_by_name
19
+
20
+ MAX_RESULT_LINES = 10
21
+
22
+ _CSS = """\
23
+ :root {
24
+ --bg0: #282828; --bg1: #3c3836; --bg2: #504945;
25
+ --fg: #ebdbb2; --fg2: #bdae93; --fg3: #a89984; --fg4: #928374;
26
+ --red: #fb4934; --green: #b8bb26; --yellow: #fabd2f;
27
+ --blue: #83a598; --orange: #fe8019; --purple: #d3869b;
28
+ }
29
+ * { margin: 0; padding: 0; box-sizing: border-box; }
30
+ body {
31
+ background: var(--bg0);
32
+ color: var(--fg);
33
+ font-family: 'SF Mono', 'JetBrains Mono', 'Menlo', monospace;
34
+ font-size: 12px;
35
+ line-height: 1.5;
36
+ padding: 24px;
37
+ max-width: 960px;
38
+ margin: 0 auto;
39
+ }
40
+ a { color: var(--blue); }
41
+ .header {
42
+ margin-bottom: 16px;
43
+ }
44
+ .header h1 { font-size: 14px; color: var(--orange); font-weight: 600; }
45
+ .header .meta { color: var(--fg4); font-size: 11px; margin-top: 4px; }
46
+ .msg-user {
47
+ color: var(--green);
48
+ white-space: pre-wrap;
49
+ }
50
+ .msg-assistant .text {
51
+ color: var(--fg);
52
+ white-space: pre-wrap;
53
+ }
54
+ .msg-assistant > * + * {
55
+ margin-top: 6px;
56
+ }
57
+ .thinking {
58
+ color: var(--fg4);
59
+ font-style: italic;
60
+ font-size: 11px;
61
+ white-space: pre-wrap;
62
+ }
63
+ .system-section {
64
+ color: var(--purple);
65
+ padding: 8px 12px;
66
+ margin: 8px 0;
67
+ background: rgba(211,134,155,0.06);
68
+ font-size: 11px;
69
+ }
70
+ .system-section .section-label {
71
+ color: var(--purple);
72
+ font-weight: 600;
73
+ font-size: 11px;
74
+ opacity: 0.7;
75
+ }
76
+ .system-content {
77
+ white-space: pre-wrap;
78
+ }
79
+ .system-section .section-label + .system-content {
80
+ margin-top: 4px;
81
+ }
82
+ .system-section .section-label + .tool-def {
83
+ margin-top: 10px;
84
+ }
85
+ .tool-def {
86
+ color: var(--purple);
87
+ font-size: 11px;
88
+ }
89
+ .tool-def + .tool-def {
90
+ margin-top: 10px;
91
+ }
92
+ .tool-def-inner {
93
+ margin-top: 2px;
94
+ }
95
+ .tool-def .tool-name {
96
+ font-weight: 600;
97
+ white-space: pre-wrap;
98
+ }
99
+ .tool-def .tool-name::before { content: "* "; }
100
+ .tool-def .tool-desc {
101
+ margin-top: 2px;
102
+ color: var(--purple);
103
+ opacity: 0.7;
104
+ white-space: pre-wrap;
105
+ }
106
+ .tool-def .tool-params {
107
+ margin-top: 2px;
108
+ color: var(--purple);
109
+ white-space: pre-wrap;
110
+ }
111
+ .system-msg {
112
+ color: var(--fg4);
113
+ font-style: italic;
114
+ font-size: 11px;
115
+ white-space: pre-wrap;
116
+ margin: 8px 0;
117
+ }
118
+ .sep {
119
+ border-top: 1px solid var(--bg2);
120
+ margin: 8px 0;
121
+ }
122
+ .tool-block {
123
+ background: var(--bg1);
124
+ padding: 6px 8px;
125
+ border-radius: 3px;
126
+ }
127
+ .tool-header { color: var(--yellow); font-weight: 600; }
128
+ .tool-call-args {
129
+ color: var(--fg2);
130
+ white-space: pre-wrap;
131
+ font-size: 11px;
132
+ margin-top: 2px;
133
+ }
134
+ .tool-result {
135
+ color: var(--fg3);
136
+ white-space: pre-wrap;
137
+ font-size: 11px;
138
+ overflow-x: auto;
139
+ margin-top: 4px;
140
+ padding-top: 4px;
141
+ border-top: 1px solid var(--bg2);
142
+ }
143
+ .tool-result.error { color: var(--red); }
144
+ """
145
+
146
+ _RICH_TAG_RE = re.compile(r"\[/?(?:[a-zA-Z0-9#._-]+)\]")
147
+
148
+
149
+ @dataclass(frozen=True)
150
+ class TokenTotals:
151
+ input_tokens: int = 0
152
+ output_tokens: int = 0
153
+ cache_read_tokens: int = 0
154
+ cache_write_tokens: int = 0
155
+
156
+
157
+ @dataclass(frozen=True)
158
+ class SessionExportData:
159
+ session_id: str
160
+ session_file: Path
161
+ created_at: str | None
162
+ system_prompt: str | None
163
+ tools: list[Any] | None
164
+ entries: list[dict[str, Any]]
165
+ model_id: str
166
+ provider: str
167
+ tokens: TokenTotals
168
+
169
+
170
+ def _strip_rich_markup(text: str) -> str:
171
+ return _RICH_TAG_RE.sub("", text)
172
+
173
+
174
+ def _esc(text: str) -> str:
175
+ return html.escape(_strip_rich_markup(text))
176
+
177
+
178
+ def _safe_cwd(cwd: str) -> str:
179
+ return cwd.replace("/", "-").replace("\\", "-").strip("-")
180
+
181
+
182
+ def _get_sessions_dir(cwd: str) -> Path:
183
+ return get_config_dir() / "sessions" / _safe_cwd(cwd)
184
+
185
+
186
+ def _read_session_header(path: Path) -> dict[str, Any] | None:
187
+ with path.open(encoding="utf-8") as f:
188
+ for line in f:
189
+ line = line.strip()
190
+ if not line:
191
+ continue
192
+ data = json.loads(line)
193
+ return data if data.get("type") == "header" else None
194
+ return None
195
+
196
+
197
+ def _resolve_session_file(cwd: str, session_id: str) -> Path:
198
+ normalized_id = session_id.strip().lower()
199
+ if not normalized_id:
200
+ raise ValueError("Session ID cannot be empty")
201
+
202
+ sessions_dir = _get_sessions_dir(cwd)
203
+ if not sessions_dir.exists():
204
+ raise FileNotFoundError(f"No sessions found for cwd: {cwd}")
205
+
206
+ exact_matches: list[Path] = []
207
+ prefix_matches: list[Path] = []
208
+
209
+ for path in sessions_dir.glob("*.jsonl"):
210
+ try:
211
+ header = _read_session_header(path)
212
+ except (OSError, json.JSONDecodeError):
213
+ continue
214
+ if not header:
215
+ continue
216
+ current_id = str(header.get("id", "")).lower()
217
+ if current_id == normalized_id:
218
+ exact_matches.append(path)
219
+ elif current_id.startswith(normalized_id):
220
+ prefix_matches.append(path)
221
+
222
+ if len(exact_matches) == 1:
223
+ return exact_matches[0]
224
+ if len(exact_matches) > 1:
225
+ raise ValueError(f"Session ID is ambiguous: {session_id}")
226
+ if len(prefix_matches) == 1:
227
+ return prefix_matches[0]
228
+ if len(prefix_matches) > 1:
229
+ raise ValueError(f"Session ID prefix is ambiguous: {session_id}")
230
+ raise FileNotFoundError(f"Session not found: {session_id}")
231
+
232
+
233
+ def _token_totals(entries: list[dict[str, Any]]) -> TokenTotals:
234
+ input_tokens = 0
235
+ output_tokens = 0
236
+ cache_read_tokens = 0
237
+ cache_write_tokens = 0
238
+
239
+ for entry in entries:
240
+ if entry.get("type") != "message":
241
+ continue
242
+ message = entry.get("message") or {}
243
+ if message.get("role") != "assistant":
244
+ continue
245
+ usage = message.get("usage") or {}
246
+ input_tokens += int(usage.get("input_tokens") or 0)
247
+ output_tokens += int(usage.get("output_tokens") or 0)
248
+ cache_read_tokens += int(usage.get("cache_read_tokens") or 0)
249
+ cache_write_tokens += int(usage.get("cache_write_tokens") or 0)
250
+
251
+ return TokenTotals(
252
+ input_tokens=input_tokens,
253
+ output_tokens=output_tokens,
254
+ cache_read_tokens=cache_read_tokens,
255
+ cache_write_tokens=cache_write_tokens,
256
+ )
257
+
258
+
259
+ def _last_model(entries: list[dict[str, Any]]) -> tuple[str, str]:
260
+ for entry in reversed(entries):
261
+ if entry.get("type") == "model_change":
262
+ return str(entry.get("model_id") or "unknown"), str(entry.get("provider") or "unknown")
263
+ return "unknown", "unknown"
264
+
265
+
266
+ def _load_session_export_data(path: Path) -> SessionExportData:
267
+ header: dict[str, Any] | None = None
268
+ entries: list[dict[str, Any]] = []
269
+
270
+ with path.open(encoding="utf-8") as f:
271
+ for line in f:
272
+ line = line.strip()
273
+ if not line:
274
+ continue
275
+ data = json.loads(line)
276
+ if data.get("type") == "header" and header is None:
277
+ header = data
278
+ else:
279
+ entries.append(data)
280
+
281
+ if not header:
282
+ raise ValueError(f"Invalid session file (missing header): {path}")
283
+
284
+ model_id, provider = _last_model(entries)
285
+ return SessionExportData(
286
+ session_id=str(header.get("id") or path.stem),
287
+ session_file=path,
288
+ created_at=header.get("timestamp"),
289
+ system_prompt=header.get("system_prompt"),
290
+ tools=header.get("tools"),
291
+ entries=entries,
292
+ model_id=model_id,
293
+ provider=provider,
294
+ tokens=_token_totals(entries),
295
+ )
296
+
297
+
298
+ def _escape_inline_newlines(text: str) -> str:
299
+ return text.replace("\\", "\\\\").replace("\r", "\\r").replace("\n", "\\n")
300
+
301
+
302
+ def _format_arg_value(value: Any) -> str:
303
+ if isinstance(value, str):
304
+ return _truncate_inline(_escape_inline_newlines(value))
305
+ try:
306
+ rendered = json.dumps(value, ensure_ascii=False)
307
+ except (TypeError, ValueError):
308
+ rendered = str(value)
309
+ return _truncate_inline(_escape_inline_newlines(rendered))
310
+
311
+
312
+ def _format_aligned_kv_lines(items: list[tuple[str, str]]) -> list[str]:
313
+ if not items:
314
+ return []
315
+ max_width = max(len(key) for key, _ in items)
316
+ lines: list[str] = []
317
+ for key, value in items:
318
+ prefix = f"{key.ljust(max_width)} :"
319
+ lines.append(f"{prefix} {value}" if value else prefix)
320
+ return lines
321
+
322
+
323
+ def _format_tool_call_args(tool_call: dict[str, Any] | None) -> str:
324
+ if tool_call is None:
325
+ return ""
326
+
327
+ arguments = tool_call.get("arguments")
328
+ if not arguments:
329
+ return ""
330
+ if isinstance(arguments, dict):
331
+ items = [(str(key), _format_arg_value(value)) for key, value in arguments.items()]
332
+ return "\n".join(_format_aligned_kv_lines(items))
333
+ if isinstance(arguments, str):
334
+ return _truncate_inline(_escape_inline_newlines(arguments))
335
+ try:
336
+ rendered = json.dumps(arguments, ensure_ascii=False, sort_keys=True)
337
+ except (TypeError, ValueError):
338
+ rendered = str(arguments)
339
+ return _truncate_inline(_escape_inline_newlines(rendered))
340
+
341
+
342
+ def _truncate(text: str, max_lines: int = MAX_RESULT_LINES) -> str:
343
+ if not text:
344
+ return text
345
+
346
+ lines = text.split("\n")
347
+ if len(lines) > max_lines:
348
+ hidden = len(lines) - max_lines
349
+ lines = lines[:max_lines]
350
+ lines.append(f"... ({hidden} lines hidden)")
351
+ return "\n".join(lines)
352
+
353
+
354
+ def _truncate_inline(text: str, max_chars: int = 72) -> str:
355
+ if len(text) <= max_chars:
356
+ return text
357
+ return text[: max_chars - 4].rstrip() + " ..."
358
+
359
+
360
+ def _format_name(name: str) -> str:
361
+ return " ".join(word.capitalize() for word in name.split("_"))
362
+
363
+
364
+ def _iter_content_parts(content: Any) -> list[dict[str, Any]]:
365
+ if isinstance(content, list):
366
+ return [part for part in content if isinstance(part, dict)]
367
+ return []
368
+
369
+
370
+ def _render_message_content(content: Any) -> str:
371
+ if isinstance(content, str):
372
+ return _esc(content)
373
+
374
+ parts: list[str] = []
375
+ for part in _iter_content_parts(content):
376
+ part_type = part.get("type")
377
+ if part_type == "text":
378
+ parts.append(_esc(str(part.get("text") or "")))
379
+ elif part_type == "image":
380
+ parts.append('<span style="color:var(--fg4)">[image]</span>')
381
+ return "".join(parts)
382
+
383
+
384
+ def _extract_text_content(content: Any) -> str:
385
+ parts: list[str] = []
386
+ for part in _iter_content_parts(content):
387
+ part_type = part.get("type")
388
+ if part_type == "text":
389
+ parts.append(str(part.get("text") or ""))
390
+ elif part_type == "image":
391
+ parts.append("[image]")
392
+ return "".join(parts)
393
+
394
+
395
+ def _schema_param_lines(schema: dict[str, Any] | None) -> list[str]:
396
+ if not isinstance(schema, dict):
397
+ return []
398
+
399
+ props = schema.get("properties")
400
+ if not isinstance(props, dict):
401
+ return []
402
+
403
+ items: list[tuple[str, str]] = []
404
+ for param_name, param_value in props.items():
405
+ if not isinstance(param_value, dict):
406
+ items.append((str(param_name), ""))
407
+ continue
408
+ param_desc = str(param_value.get("description") or "").strip()
409
+ param_type = str(param_value.get("type") or "").strip()
410
+ if param_desc:
411
+ items.append((str(param_name), param_desc))
412
+ elif param_type:
413
+ items.append((str(param_name), param_type))
414
+ else:
415
+ items.append((str(param_name), ""))
416
+ return _format_aligned_kv_lines(items)
417
+
418
+
419
+ def _tool_definition_parts(tool_item: Any) -> tuple[str, str | None, list[str]]:
420
+ if isinstance(tool_item, str):
421
+ tool = tools_by_name.get(tool_item)
422
+ if tool:
423
+ return (
424
+ tool_item,
425
+ tool.description,
426
+ _schema_param_lines(tool.params.model_json_schema()),
427
+ )
428
+ return tool_item, None, []
429
+
430
+ if not isinstance(tool_item, dict):
431
+ return str(tool_item), None, []
432
+
433
+ name = str(tool_item.get("name") or tool_item.get("id") or "unknown")
434
+ description = tool_item.get("description")
435
+ desc = str(description) if isinstance(description, str) and description else None
436
+ schema = tool_item.get("parameters") or tool_item.get("params")
437
+ param_lines = _schema_param_lines(schema)
438
+ if desc or param_lines:
439
+ return name, desc, param_lines
440
+
441
+ tool = tools_by_name.get(name)
442
+ if tool:
443
+ return name, tool.description, _schema_param_lines(tool.params.model_json_schema())
444
+ return name, None, []
445
+
446
+
447
+ class HtmlBuilder:
448
+ def __init__(self) -> None:
449
+ self._parts: list[str] = []
450
+ self._assistant_open = False
451
+ self._last_block_kind: str | None = None
452
+
453
+ def _append(self, text: str) -> None:
454
+ self._parts.append(text)
455
+
456
+ def _before_chat_block(self) -> None:
457
+ self.close_assistant()
458
+ if self._last_block_kind == "chat":
459
+ self._append('<div class="sep"></div>')
460
+ self._last_block_kind = "chat"
461
+
462
+ def open_assistant(self) -> None:
463
+ if not self._assistant_open:
464
+ self._before_chat_block()
465
+ self._append('<div class="msg msg-assistant">')
466
+ self._assistant_open = True
467
+
468
+ def close_assistant(self) -> None:
469
+ if self._assistant_open:
470
+ self._append("</div>")
471
+ self._assistant_open = False
472
+
473
+ def header(self, version: str, session: SessionExportData) -> None:
474
+ token_parts = [f"↑{session.tokens.input_tokens:,}", f"↓{session.tokens.output_tokens:,}"]
475
+ if session.tokens.cache_read_tokens:
476
+ token_parts.append(f"R{session.tokens.cache_read_tokens:,}")
477
+ if session.tokens.cache_write_tokens:
478
+ token_parts.append(f"W{session.tokens.cache_write_tokens:,}")
479
+
480
+ model_str = (
481
+ session.model_id
482
+ if session.provider == "unknown"
483
+ else f"{session.model_id} ({session.provider})"
484
+ )
485
+ created = session.created_at or "unknown"
486
+ if "T" in created:
487
+ with suppress(ValueError):
488
+ created = datetime.fromisoformat(created).strftime("%Y-%m-%d %H:%M")
489
+
490
+ self._append('<div class="header">')
491
+ self._append(f"<h1>vtx {_esc(version)}</h1>")
492
+ self._append(
493
+ f'<div class="meta">session {session.session_id[:8]}'
494
+ f" · {_esc(created)} · {_esc(model_str)}"
495
+ f" · {' '.join(token_parts)}</div>"
496
+ )
497
+ self._append("</div>")
498
+
499
+ def user_message(self, message: dict[str, Any]) -> None:
500
+ self._before_chat_block()
501
+ self._append(
502
+ f'<div class="msg-user">&gt; {_render_message_content(message.get("content"))}</div>'
503
+ )
504
+
505
+ def assistant_text(self, text: str) -> None:
506
+ self.open_assistant()
507
+ self._append(f'<div class="text">{_esc(text)}</div>')
508
+
509
+ def thinking(self, text: str) -> None:
510
+ self.open_assistant()
511
+ self._append(f'<div class="thinking">{_esc(text)}</div>')
512
+
513
+ def tool_block(self, name: str, args: str, result_text: str = "", error: bool = False) -> None:
514
+ self.open_assistant()
515
+ parts = [f'<div class="tool-header">{_esc(name)}</div>']
516
+ if args:
517
+ parts.append(f'<div class="tool-call-args">{_esc(args)}</div>')
518
+ if result_text:
519
+ klass = "tool-result error" if error else "tool-result"
520
+ parts.append(f'<div class="{klass}">{_esc(result_text)}</div>')
521
+ self._append(f'<div class="tool-block">{"".join(parts)}</div>')
522
+
523
+ def system_section(self, system_prompt: str | None, tools: list[Any] | None) -> None:
524
+ self.close_assistant()
525
+ self._last_block_kind = "system"
526
+ if system_prompt:
527
+ self._append('<div class="system-section">')
528
+ self._append('<div class="section-label">System Prompt</div>')
529
+ self._append(f'<div class="system-content">{_esc(system_prompt)}</div>')
530
+ self._append("</div>")
531
+ if tools:
532
+ self._append('<div class="system-section">')
533
+ self._append('<div class="section-label">Tools</div>')
534
+ for tool_item in tools:
535
+ name, description, param_lines = _tool_definition_parts(tool_item)
536
+ self._append('<div class="tool-def">')
537
+ self._append('<div class="tool-def-inner">')
538
+ self._append(f'<div class="tool-name">{_esc(name)}</div>')
539
+ if description:
540
+ self._append(f'<div class="tool-desc">{_esc(description)}</div>')
541
+ if param_lines:
542
+ params_html = "<br>".join(_esc(line) for line in param_lines)
543
+ self._append(f'<div class="tool-params">{params_html}</div>')
544
+ self._append("</div>")
545
+ self._append("</div>")
546
+ self._append("</div>")
547
+
548
+ def system_message(self, text: str) -> None:
549
+ self.close_assistant()
550
+ self._last_block_kind = "system"
551
+ self._append(f'<div class="system-msg">{_esc(text)}</div>')
552
+
553
+ def build(self) -> str:
554
+ self.close_assistant()
555
+ body = "\n".join(self._parts)
556
+ return f"""\
557
+ <!DOCTYPE html>
558
+ <html lang=\"en\">
559
+ <head>
560
+ <meta charset=\"UTF-8\">
561
+ <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">
562
+ <title>vtx - Export</title>
563
+ <style>
564
+ {_CSS}
565
+ </style>
566
+ </head>
567
+ <body>
568
+ {body}
569
+ </body>
570
+ </html>"""
571
+
572
+
573
+ class ExportRenderer:
574
+ def __init__(self, builder: HtmlBuilder) -> None:
575
+ self.builder = builder
576
+ self.pending_tool_calls: dict[str, dict[str, Any]] = {}
577
+ self.in_assistant_turn = False
578
+
579
+ def _flush_pending_tool_calls(self) -> None:
580
+ for tool_call in self.pending_tool_calls.values():
581
+ self.builder.tool_block(
582
+ _format_name(str(tool_call.get("name") or "tool")),
583
+ _format_tool_call_args(tool_call),
584
+ )
585
+ self.pending_tool_calls.clear()
586
+
587
+ def _end_assistant_turn(self) -> None:
588
+ if not self.in_assistant_turn:
589
+ return
590
+ self._flush_pending_tool_calls()
591
+ self.builder.close_assistant()
592
+ self.in_assistant_turn = False
593
+
594
+ def _pop_pending_tool_call(self, tool_call_id: str) -> dict[str, Any] | None:
595
+ tool_call = self.pending_tool_calls.pop(tool_call_id, None)
596
+ if tool_call is not None:
597
+ return tool_call
598
+
599
+ normalized_id = tool_call_id.split("|", 1)[0]
600
+ if normalized_id != tool_call_id:
601
+ tool_call = self.pending_tool_calls.pop(normalized_id, None)
602
+ if tool_call is not None:
603
+ return tool_call
604
+
605
+ for pending_id in list(self.pending_tool_calls):
606
+ if pending_id.split("|", 1)[0] == normalized_id:
607
+ return self.pending_tool_calls.pop(pending_id)
608
+ return None
609
+
610
+ def render_entry(self, entry: dict[str, Any]) -> None:
611
+ entry_type = entry.get("type")
612
+
613
+ if entry_type == "message":
614
+ message = entry.get("message") or {}
615
+ role = message.get("role")
616
+
617
+ if role == "user":
618
+ self._end_assistant_turn()
619
+ self.builder.user_message(message)
620
+ return
621
+
622
+ if role == "assistant":
623
+ self.in_assistant_turn = True
624
+ for part in _iter_content_parts(message.get("content")):
625
+ part_type = part.get("type")
626
+ if part_type == "text" and part.get("text"):
627
+ self.builder.assistant_text(str(part.get("text") or ""))
628
+ elif part_type == "thinking" and part.get("thinking"):
629
+ self.builder.thinking(str(part.get("thinking") or ""))
630
+ elif part_type == "tool_call" and part.get("id"):
631
+ self.pending_tool_calls[str(part.get("id"))] = part
632
+ return
633
+
634
+ if role == "tool_result":
635
+ self.in_assistant_turn = True
636
+ tool_call_id = str(message.get("tool_call_id") or "")
637
+ tool_call = self._pop_pending_tool_call(tool_call_id)
638
+ tool_name = str(message.get("tool_name") or "tool")
639
+ name = (
640
+ _format_name(str(tool_call.get("name") or tool_name))
641
+ if tool_call
642
+ else _format_name(tool_name)
643
+ )
644
+ args = _format_tool_call_args(tool_call)
645
+
646
+ if message.get("is_error"):
647
+ text = _extract_text_content(message.get("content")).strip()
648
+ result = f"-- {text} --" if text else ""
649
+ self.builder.tool_block(name, args, result_text=result, error=True)
650
+ return
651
+
652
+ result_source = message.get("ui_details")
653
+ if not isinstance(result_source, str) or not result_source:
654
+ result_source = _extract_text_content(message.get("content"))
655
+ self.builder.tool_block(name, args, result_text=_truncate(result_source))
656
+ return
657
+
658
+ self._end_assistant_turn()
659
+
660
+ if entry_type == "custom_message" and entry.get("custom_type") == "shell_command":
661
+ details = entry.get("details") or {}
662
+ command = str(details.get("command") or entry.get("content") or "")
663
+ output = str(details.get("output") or "")
664
+ success = bool(details.get("success", True))
665
+ self.builder.tool_block(
666
+ "Bash", f"$ {command}", result_text=_truncate(output), error=not success
667
+ )
668
+ return
669
+
670
+ if entry_type == "model_change":
671
+ model_id = entry.get("model_id") or "unknown"
672
+ provider = entry.get("provider") or "unknown"
673
+ self.builder.system_message(f"Model changed to {model_id} ({provider})")
674
+ elif entry_type == "thinking_level_change":
675
+ self.builder.system_message(
676
+ f"Thinking level: {entry.get('thinking_level') or 'unknown'}"
677
+ )
678
+ elif entry_type == "compaction":
679
+ self.builder.system_message("Context compacted")
680
+ elif entry_type == "custom_message" and entry.get("display", True):
681
+ self.builder.system_message(str(entry.get("content") or ""))
682
+
683
+ def finish(self) -> None:
684
+ self._end_assistant_turn()
685
+
686
+
687
+ def export_session_html(cwd: str, session_id: str, output_dir: str, version: str = "") -> Path:
688
+ session_file = _resolve_session_file(cwd, session_id)
689
+ session = _load_session_export_data(session_file)
690
+
691
+ builder = HtmlBuilder()
692
+ builder.header(version, session)
693
+ if session.system_prompt or session.tools:
694
+ builder.system_section(session.system_prompt, session.tools)
695
+
696
+ renderer = ExportRenderer(builder)
697
+ for entry in session.entries:
698
+ renderer.render_entry(entry)
699
+ renderer.finish()
700
+
701
+ output_path = Path(output_dir) / f"vtx-session-{session.session_file.stem}.html"
702
+ output_path.write_text(builder.build(), encoding="utf-8")
703
+ return output_path