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.
- vtx/__init__.py +63 -0
- vtx/async_utils.py +40 -0
- vtx/builtin_skills/github/SKILL.md +139 -0
- vtx/builtin_skills/init/SKILL.md +74 -0
- vtx/builtin_skills/review/SKILL.md +73 -0
- vtx/builtin_skills/skill-builder/SKILL.md +133 -0
- vtx/cli.py +90 -0
- vtx/config.py +741 -0
- vtx/context/__init__.py +15 -0
- vtx/context/_xml.py +8 -0
- vtx/context/agent_mds.py +128 -0
- vtx/context/git.py +64 -0
- vtx/context/loader.py +41 -0
- vtx/context/skills.py +423 -0
- vtx/core/__init__.py +47 -0
- vtx/core/compaction.py +89 -0
- vtx/core/errors.py +17 -0
- vtx/core/handoff.py +51 -0
- vtx/core/scratchpad.py +54 -0
- vtx/core/types.py +197 -0
- vtx/defaults/__init__.py +0 -0
- vtx/defaults/config.yml +53 -0
- vtx/diff_display.py +12 -0
- vtx/events.py +224 -0
- vtx/gh_cli.py +82 -0
- vtx/git_branch.py +90 -0
- vtx/headless.py +127 -0
- vtx/llm/__init__.py +93 -0
- vtx/llm/base.py +217 -0
- vtx/llm/context_length.py +150 -0
- vtx/llm/dynamic_models.py +735 -0
- vtx/llm/model_fetcher.py +279 -0
- vtx/llm/models.py +78 -0
- vtx/llm/oauth/__init__.py +59 -0
- vtx/llm/oauth/copilot.py +358 -0
- vtx/llm/oauth/dynamic.py +236 -0
- vtx/llm/oauth/openai.py +400 -0
- vtx/llm/phase_parser.py +270 -0
- vtx/llm/provider.yaml +280 -0
- vtx/llm/provider_catalog.py +230 -0
- vtx/llm/providers/__init__.py +45 -0
- vtx/llm/providers/anthropic_sdk.py +256 -0
- vtx/llm/providers/mock.py +249 -0
- vtx/llm/providers/openai_sdk.py +246 -0
- vtx/llm/providers/sanitize.py +14 -0
- vtx/llm/sdk/__init__.py +13 -0
- vtx/llm/sdk/anthropic.py +382 -0
- vtx/llm/sdk/base.py +82 -0
- vtx/llm/sdk/openai.py +344 -0
- vtx/llm/tool_parser.py +161 -0
- vtx/loop.py +272 -0
- vtx/notify.py +109 -0
- vtx/permissions.py +114 -0
- vtx/prompts/__init__.py +45 -0
- vtx/prompts/builder.py +86 -0
- vtx/prompts/env.py +58 -0
- vtx/prompts/identity.py +166 -0
- vtx/prompts/tooling.py +36 -0
- vtx/py.typed +0 -0
- vtx/runtime.py +580 -0
- vtx/session.py +868 -0
- vtx/sounds/completion.wav +0 -0
- vtx/sounds/error.wav +0 -0
- vtx/sounds/permission.wav +0 -0
- vtx/themes.py +1104 -0
- vtx/tools/__init__.py +68 -0
- vtx/tools/_read_image.py +106 -0
- vtx/tools/_tool_utils.py +90 -0
- vtx/tools/base.py +36 -0
- vtx/tools/bash.py +371 -0
- vtx/tools/edit.py +261 -0
- vtx/tools/find.py +132 -0
- vtx/tools/read.py +238 -0
- vtx/tools/skill.py +278 -0
- vtx/tools/web.py +238 -0
- vtx/tools/write.py +88 -0
- vtx/tools_manager.py +216 -0
- vtx/turn.py +789 -0
- vtx/ui/__init__.py +0 -0
- vtx/ui/agent_runner.py +417 -0
- vtx/ui/app.py +665 -0
- vtx/ui/app_protocol.py +29 -0
- vtx/ui/autocomplete.py +440 -0
- vtx/ui/blocks.py +735 -0
- vtx/ui/chat.py +613 -0
- vtx/ui/clipboard.py +59 -0
- vtx/ui/commands/__init__.py +100 -0
- vtx/ui/commands/auth.py +306 -0
- vtx/ui/commands/base.py +122 -0
- vtx/ui/commands/models.py +144 -0
- vtx/ui/commands/sessions.py +388 -0
- vtx/ui/commands/settings.py +286 -0
- vtx/ui/completion_ui.py +313 -0
- vtx/ui/export.py +703 -0
- vtx/ui/floating_list.py +370 -0
- vtx/ui/formatting.py +287 -0
- vtx/ui/input.py +760 -0
- vtx/ui/latex.py +349 -0
- vtx/ui/launch.py +108 -0
- vtx/ui/path_complete.py +228 -0
- vtx/ui/prompt_history.py +102 -0
- vtx/ui/queue_ui.py +141 -0
- vtx/ui/selection_mode.py +18 -0
- vtx/ui/session_ui.py +235 -0
- vtx/ui/startup.py +124 -0
- vtx/ui/styles.py +327 -0
- vtx/ui/tool_output.py +34 -0
- vtx/ui/tree.py +437 -0
- vtx/ui/welcome.py +51 -0
- vtx/ui/widgets.py +558 -0
- vtx/update_check.py +49 -0
- vtx/version.py +22 -0
- vtx_coding_agent-0.1.1.dist-info/METADATA +259 -0
- vtx_coding_agent-0.1.1.dist-info/RECORD +117 -0
- vtx_coding_agent-0.1.1.dist-info/WHEEL +4 -0
- vtx_coding_agent-0.1.1.dist-info/entry_points.txt +2 -0
- 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">> {_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
|