klaude-code 1.2.6__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 (167) hide show
  1. klaude_code/__init__.py +0 -0
  2. klaude_code/cli/__init__.py +1 -0
  3. klaude_code/cli/main.py +298 -0
  4. klaude_code/cli/runtime.py +331 -0
  5. klaude_code/cli/session_cmd.py +80 -0
  6. klaude_code/command/__init__.py +43 -0
  7. klaude_code/command/clear_cmd.py +20 -0
  8. klaude_code/command/command_abc.py +92 -0
  9. klaude_code/command/diff_cmd.py +138 -0
  10. klaude_code/command/export_cmd.py +86 -0
  11. klaude_code/command/help_cmd.py +51 -0
  12. klaude_code/command/model_cmd.py +43 -0
  13. klaude_code/command/prompt-dev-docs-update.md +56 -0
  14. klaude_code/command/prompt-dev-docs.md +46 -0
  15. klaude_code/command/prompt-init.md +45 -0
  16. klaude_code/command/prompt_command.py +69 -0
  17. klaude_code/command/refresh_cmd.py +43 -0
  18. klaude_code/command/registry.py +110 -0
  19. klaude_code/command/status_cmd.py +111 -0
  20. klaude_code/command/terminal_setup_cmd.py +252 -0
  21. klaude_code/config/__init__.py +11 -0
  22. klaude_code/config/config.py +177 -0
  23. klaude_code/config/list_model.py +162 -0
  24. klaude_code/config/select_model.py +67 -0
  25. klaude_code/const/__init__.py +133 -0
  26. klaude_code/core/__init__.py +0 -0
  27. klaude_code/core/agent.py +165 -0
  28. klaude_code/core/executor.py +485 -0
  29. klaude_code/core/manager/__init__.py +19 -0
  30. klaude_code/core/manager/agent_manager.py +127 -0
  31. klaude_code/core/manager/llm_clients.py +42 -0
  32. klaude_code/core/manager/llm_clients_builder.py +49 -0
  33. klaude_code/core/manager/sub_agent_manager.py +86 -0
  34. klaude_code/core/prompt.py +89 -0
  35. klaude_code/core/prompts/prompt-claude-code.md +98 -0
  36. klaude_code/core/prompts/prompt-codex.md +331 -0
  37. klaude_code/core/prompts/prompt-gemini.md +43 -0
  38. klaude_code/core/prompts/prompt-subagent-explore.md +27 -0
  39. klaude_code/core/prompts/prompt-subagent-oracle.md +23 -0
  40. klaude_code/core/prompts/prompt-subagent-webfetch.md +46 -0
  41. klaude_code/core/prompts/prompt-subagent.md +8 -0
  42. klaude_code/core/reminders.py +445 -0
  43. klaude_code/core/task.py +237 -0
  44. klaude_code/core/tool/__init__.py +75 -0
  45. klaude_code/core/tool/file/__init__.py +0 -0
  46. klaude_code/core/tool/file/apply_patch.py +492 -0
  47. klaude_code/core/tool/file/apply_patch_tool.md +1 -0
  48. klaude_code/core/tool/file/apply_patch_tool.py +204 -0
  49. klaude_code/core/tool/file/edit_tool.md +9 -0
  50. klaude_code/core/tool/file/edit_tool.py +274 -0
  51. klaude_code/core/tool/file/multi_edit_tool.md +42 -0
  52. klaude_code/core/tool/file/multi_edit_tool.py +199 -0
  53. klaude_code/core/tool/file/read_tool.md +14 -0
  54. klaude_code/core/tool/file/read_tool.py +326 -0
  55. klaude_code/core/tool/file/write_tool.md +8 -0
  56. klaude_code/core/tool/file/write_tool.py +146 -0
  57. klaude_code/core/tool/memory/__init__.py +0 -0
  58. klaude_code/core/tool/memory/memory_tool.md +16 -0
  59. klaude_code/core/tool/memory/memory_tool.py +462 -0
  60. klaude_code/core/tool/memory/skill_loader.py +245 -0
  61. klaude_code/core/tool/memory/skill_tool.md +24 -0
  62. klaude_code/core/tool/memory/skill_tool.py +97 -0
  63. klaude_code/core/tool/shell/__init__.py +0 -0
  64. klaude_code/core/tool/shell/bash_tool.md +43 -0
  65. klaude_code/core/tool/shell/bash_tool.py +123 -0
  66. klaude_code/core/tool/shell/command_safety.py +363 -0
  67. klaude_code/core/tool/sub_agent_tool.py +83 -0
  68. klaude_code/core/tool/todo/__init__.py +0 -0
  69. klaude_code/core/tool/todo/todo_write_tool.md +182 -0
  70. klaude_code/core/tool/todo/todo_write_tool.py +121 -0
  71. klaude_code/core/tool/todo/update_plan_tool.md +3 -0
  72. klaude_code/core/tool/todo/update_plan_tool.py +104 -0
  73. klaude_code/core/tool/tool_abc.py +25 -0
  74. klaude_code/core/tool/tool_context.py +106 -0
  75. klaude_code/core/tool/tool_registry.py +78 -0
  76. klaude_code/core/tool/tool_runner.py +252 -0
  77. klaude_code/core/tool/truncation.py +170 -0
  78. klaude_code/core/tool/web/__init__.py +0 -0
  79. klaude_code/core/tool/web/mermaid_tool.md +21 -0
  80. klaude_code/core/tool/web/mermaid_tool.py +76 -0
  81. klaude_code/core/tool/web/web_fetch_tool.md +8 -0
  82. klaude_code/core/tool/web/web_fetch_tool.py +159 -0
  83. klaude_code/core/turn.py +220 -0
  84. klaude_code/llm/__init__.py +21 -0
  85. klaude_code/llm/anthropic/__init__.py +3 -0
  86. klaude_code/llm/anthropic/client.py +221 -0
  87. klaude_code/llm/anthropic/input.py +200 -0
  88. klaude_code/llm/client.py +49 -0
  89. klaude_code/llm/input_common.py +239 -0
  90. klaude_code/llm/openai_compatible/__init__.py +3 -0
  91. klaude_code/llm/openai_compatible/client.py +211 -0
  92. klaude_code/llm/openai_compatible/input.py +109 -0
  93. klaude_code/llm/openai_compatible/tool_call_accumulator.py +80 -0
  94. klaude_code/llm/openrouter/__init__.py +3 -0
  95. klaude_code/llm/openrouter/client.py +200 -0
  96. klaude_code/llm/openrouter/input.py +160 -0
  97. klaude_code/llm/openrouter/reasoning_handler.py +209 -0
  98. klaude_code/llm/registry.py +22 -0
  99. klaude_code/llm/responses/__init__.py +3 -0
  100. klaude_code/llm/responses/client.py +216 -0
  101. klaude_code/llm/responses/input.py +167 -0
  102. klaude_code/llm/usage.py +109 -0
  103. klaude_code/protocol/__init__.py +4 -0
  104. klaude_code/protocol/commands.py +21 -0
  105. klaude_code/protocol/events.py +163 -0
  106. klaude_code/protocol/llm_param.py +147 -0
  107. klaude_code/protocol/model.py +287 -0
  108. klaude_code/protocol/op.py +89 -0
  109. klaude_code/protocol/op_handler.py +28 -0
  110. klaude_code/protocol/sub_agent.py +348 -0
  111. klaude_code/protocol/tools.py +15 -0
  112. klaude_code/session/__init__.py +4 -0
  113. klaude_code/session/export.py +624 -0
  114. klaude_code/session/selector.py +76 -0
  115. klaude_code/session/session.py +474 -0
  116. klaude_code/session/templates/export_session.html +1434 -0
  117. klaude_code/trace/__init__.py +3 -0
  118. klaude_code/trace/log.py +168 -0
  119. klaude_code/ui/__init__.py +91 -0
  120. klaude_code/ui/core/__init__.py +1 -0
  121. klaude_code/ui/core/display.py +103 -0
  122. klaude_code/ui/core/input.py +71 -0
  123. klaude_code/ui/core/stage_manager.py +55 -0
  124. klaude_code/ui/modes/__init__.py +1 -0
  125. klaude_code/ui/modes/debug/__init__.py +1 -0
  126. klaude_code/ui/modes/debug/display.py +36 -0
  127. klaude_code/ui/modes/exec/__init__.py +1 -0
  128. klaude_code/ui/modes/exec/display.py +63 -0
  129. klaude_code/ui/modes/repl/__init__.py +51 -0
  130. klaude_code/ui/modes/repl/clipboard.py +152 -0
  131. klaude_code/ui/modes/repl/completers.py +429 -0
  132. klaude_code/ui/modes/repl/display.py +60 -0
  133. klaude_code/ui/modes/repl/event_handler.py +375 -0
  134. klaude_code/ui/modes/repl/input_prompt_toolkit.py +198 -0
  135. klaude_code/ui/modes/repl/key_bindings.py +170 -0
  136. klaude_code/ui/modes/repl/renderer.py +281 -0
  137. klaude_code/ui/renderers/__init__.py +0 -0
  138. klaude_code/ui/renderers/assistant.py +21 -0
  139. klaude_code/ui/renderers/common.py +8 -0
  140. klaude_code/ui/renderers/developer.py +158 -0
  141. klaude_code/ui/renderers/diffs.py +215 -0
  142. klaude_code/ui/renderers/errors.py +16 -0
  143. klaude_code/ui/renderers/metadata.py +190 -0
  144. klaude_code/ui/renderers/sub_agent.py +71 -0
  145. klaude_code/ui/renderers/thinking.py +39 -0
  146. klaude_code/ui/renderers/tools.py +551 -0
  147. klaude_code/ui/renderers/user_input.py +65 -0
  148. klaude_code/ui/rich/__init__.py +1 -0
  149. klaude_code/ui/rich/live.py +65 -0
  150. klaude_code/ui/rich/markdown.py +308 -0
  151. klaude_code/ui/rich/quote.py +34 -0
  152. klaude_code/ui/rich/searchable_text.py +71 -0
  153. klaude_code/ui/rich/status.py +240 -0
  154. klaude_code/ui/rich/theme.py +274 -0
  155. klaude_code/ui/terminal/__init__.py +1 -0
  156. klaude_code/ui/terminal/color.py +244 -0
  157. klaude_code/ui/terminal/control.py +147 -0
  158. klaude_code/ui/terminal/notifier.py +107 -0
  159. klaude_code/ui/terminal/progress_bar.py +87 -0
  160. klaude_code/ui/utils/__init__.py +1 -0
  161. klaude_code/ui/utils/common.py +108 -0
  162. klaude_code/ui/utils/debouncer.py +42 -0
  163. klaude_code/version.py +163 -0
  164. klaude_code-1.2.6.dist-info/METADATA +178 -0
  165. klaude_code-1.2.6.dist-info/RECORD +167 -0
  166. klaude_code-1.2.6.dist-info/WHEEL +4 -0
  167. klaude_code-1.2.6.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,624 @@
1
+ """Session export functionality for generating HTML transcripts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import importlib.resources
7
+ import json
8
+ import re
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from string import Template
12
+ from typing import TYPE_CHECKING, Any, Final, cast
13
+
14
+ from klaude_code.protocol import llm_param, model
15
+ from klaude_code.protocol.sub_agent import is_sub_agent_tool
16
+
17
+ if TYPE_CHECKING:
18
+ from klaude_code.session.session import Session
19
+
20
+ _TOOL_OUTPUT_PREVIEW_LINES: Final[int] = 12
21
+ _MAX_FILENAME_MESSAGE_LEN: Final[int] = 50
22
+
23
+
24
+ def _sanitize_filename(text: str) -> str:
25
+ """Sanitize text for use in filename."""
26
+ sanitized = re.sub(r"[^\w\s\u4e00-\u9fff-]", "", text)
27
+ sanitized = re.sub(r"\s+", "_", sanitized.strip())
28
+ return sanitized[:_MAX_FILENAME_MESSAGE_LEN] if sanitized else "export"
29
+
30
+
31
+ def _escape_html(text: str) -> str:
32
+ return html.escape(text, quote=True).replace("'", "'")
33
+
34
+
35
+ def _shorten_path(path: str) -> str:
36
+ home = str(Path.home())
37
+ if path.startswith(home):
38
+ return path.replace(home, "~", 1)
39
+ return path
40
+
41
+
42
+ def _format_timestamp(value: float | None) -> str:
43
+ if not value or value <= 0:
44
+ return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
45
+ return datetime.fromtimestamp(value).strftime("%Y-%m-%d %H:%M:%S")
46
+
47
+
48
+ def _format_msg_timestamp(dt: datetime) -> str:
49
+ return dt.strftime("%Y-%m-%d %H:%M:%S")
50
+
51
+
52
+ def get_first_user_message(history: list[model.ConversationItem]) -> str:
53
+ """Extract the first user message content from conversation history."""
54
+ for item in history:
55
+ if isinstance(item, model.UserMessageItem) and item.content:
56
+ content = item.content.strip()
57
+ first_line = content.split("\n")[0]
58
+ return first_line[:100] if len(first_line) > 100 else first_line
59
+ return "export"
60
+
61
+
62
+ def get_default_export_path(session: Session) -> Path:
63
+ """Get default export path for a session."""
64
+ from klaude_code.session.session import Session as SessionClass
65
+
66
+ exports_dir = SessionClass._exports_dir() # pyright: ignore[reportPrivateUsage]
67
+ timestamp = datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
68
+ first_msg = get_first_user_message(session.conversation_history)
69
+ sanitized_msg = _sanitize_filename(first_msg)
70
+ filename = f"{timestamp}_{sanitized_msg}.html"
71
+ return exports_dir / filename
72
+
73
+
74
+ def _load_template() -> str:
75
+ """Load the HTML template from the templates directory."""
76
+ template_file = importlib.resources.files("klaude_code.session.templates").joinpath("export_session.html")
77
+ return template_file.read_text(encoding="utf-8")
78
+
79
+
80
+ def _build_tools_html(tools: list[llm_param.ToolSchema]) -> str:
81
+ if not tools:
82
+ return '<div style="padding: 12px; font-style: italic;">No tools registered for this session.</div>'
83
+ chunks: list[str] = []
84
+ for tool in tools:
85
+ name = _escape_html(tool.name)
86
+ description = _escape_html(tool.description)
87
+ params_html = _build_tool_params_html(tool.parameters)
88
+ chunks.append(
89
+ f'<details class="tool-details">'
90
+ f"<summary>{name}</summary>"
91
+ f'<div class="details-content">'
92
+ f'<div class="tool-description">{description}</div>'
93
+ f"{params_html}"
94
+ f"</div>"
95
+ f"</details>"
96
+ )
97
+ return "".join(chunks)
98
+
99
+
100
+ def _build_tool_params_html(parameters: dict[str, object]) -> str:
101
+ if not parameters:
102
+ return ""
103
+ properties = parameters.get("properties")
104
+ if not properties or not isinstance(properties, dict):
105
+ return ""
106
+ required_list = cast(list[str], parameters.get("required", []))
107
+ required_params: set[str] = set(required_list)
108
+
109
+ params_items: list[str] = []
110
+ typed_properties = cast(dict[str, dict[str, Any]], properties)
111
+ for param_name, param_schema in typed_properties.items():
112
+ escaped_name = _escape_html(param_name)
113
+ param_type_raw = param_schema.get("type", "any")
114
+ if isinstance(param_type_raw, list):
115
+ type_list = cast(list[str], param_type_raw)
116
+ param_type = " | ".join(type_list)
117
+ else:
118
+ param_type = str(param_type_raw)
119
+ escaped_type = _escape_html(param_type)
120
+ param_desc_raw = param_schema.get("description", "")
121
+ escaped_desc = _escape_html(str(param_desc_raw))
122
+
123
+ required_badge = ""
124
+ if param_name in required_params:
125
+ required_badge = '<span class="tool-param-required">(required)</span>'
126
+
127
+ desc_html = ""
128
+ if escaped_desc:
129
+ desc_html = f'<div class="tool-param-desc">{escaped_desc}</div>'
130
+
131
+ params_items.append(
132
+ f'<div class="tool-param">'
133
+ f'<span class="tool-param-name">{escaped_name}</span> '
134
+ f'<span class="tool-param-type">[{escaped_type}]</span>'
135
+ f"{required_badge}"
136
+ f"{desc_html}"
137
+ f"</div>"
138
+ )
139
+
140
+ if not params_items:
141
+ return ""
142
+
143
+ return f'<div class="tool-params"><div class="tool-params-title">Parameters:</div>{"".join(params_items)}</div>'
144
+
145
+
146
+ def _format_token_count(count: int) -> str:
147
+ if count < 1000:
148
+ return str(count)
149
+ if count < 1000000:
150
+ k = count / 1000
151
+ return f"{int(k)}k" if k.is_integer() else f"{k:.1f}k"
152
+ m = count // 1000000
153
+ rem = (count % 1000000) // 1000
154
+ return f"{m}M" if rem == 0 else f"{m}M{rem}k"
155
+
156
+
157
+ def _format_cost(cost: float) -> str:
158
+ return f"${cost:.4f}"
159
+
160
+
161
+ def _render_metadata_item(item: model.ResponseMetadataItem) -> str:
162
+ # Model Name [@ Provider]
163
+ parts: list[str] = []
164
+
165
+ model_parts = [f'<span class="metadata-model">{_escape_html(item.model_name)}</span>']
166
+ if item.provider:
167
+ provider = _escape_html(item.provider.lower().replace(" ", "-"))
168
+ model_parts.append(f'<span class="metadata-provider">@{provider}</span>')
169
+
170
+ parts.append("".join(model_parts))
171
+
172
+ # Stats
173
+ if item.usage:
174
+ u = item.usage
175
+ # Input with cost
176
+ input_stat = f"input: {_format_token_count(u.input_tokens)}"
177
+ if u.input_cost is not None:
178
+ input_stat += f"({_format_cost(u.input_cost)})"
179
+ parts.append(f'<span class="metadata-stat">{input_stat}</span>')
180
+
181
+ # Cached with cost
182
+ if u.cached_tokens > 0:
183
+ cached_stat = f"cached: {_format_token_count(u.cached_tokens)}"
184
+ if u.cache_read_cost is not None:
185
+ cached_stat += f"({_format_cost(u.cache_read_cost)})"
186
+ parts.append(f'<span class="metadata-stat">{cached_stat}</span>')
187
+
188
+ # Output with cost
189
+ output_stat = f"output: {_format_token_count(u.output_tokens)}"
190
+ if u.output_cost is not None:
191
+ output_stat += f"({_format_cost(u.output_cost)})"
192
+ parts.append(f'<span class="metadata-stat">{output_stat}</span>')
193
+
194
+ if u.reasoning_tokens > 0:
195
+ parts.append(
196
+ f'<span class="metadata-stat">thinking: {_format_token_count(u.reasoning_tokens)}</span>'
197
+ )
198
+ if u.context_usage_percent is not None:
199
+ parts.append(f'<span class="metadata-stat">context: {u.context_usage_percent:.1f}%</span>')
200
+ if u.throughput_tps is not None:
201
+ parts.append(f'<span class="metadata-stat">tps: {u.throughput_tps:.1f}</span>')
202
+
203
+ if item.task_duration_s is not None:
204
+ parts.append(f'<span class="metadata-stat">time: {item.task_duration_s:.1f}s</span>')
205
+
206
+ # Total cost
207
+ if item.usage is not None and item.usage.total_cost is not None:
208
+ parts.append(f'<span class="metadata-stat">cost: {_format_cost(item.usage.total_cost)}</span>')
209
+
210
+ divider = '<span class="metadata-divider">/</span>'
211
+ joined_html = divider.join(parts)
212
+
213
+ return (
214
+ f'<div class="response-metadata">'
215
+ f'<div class="metadata-line">{joined_html}</div>'
216
+ f"</div>"
217
+ )
218
+
219
+
220
+ def _render_assistant_message(index: int, content: str, timestamp: datetime) -> str:
221
+ encoded = _escape_html(content)
222
+ ts_str = _format_msg_timestamp(timestamp)
223
+ return (
224
+ f'<div class="message-group assistant-message-group">'
225
+ f'<div class="message-header">'
226
+ f'<div class="role-label assistant">Assistant</div>'
227
+ f'<div class="assistant-toolbar">'
228
+ f'<span class="timestamp">{ts_str}</span>'
229
+ f'<button type="button" class="raw-toggle" aria-pressed="false" title="Toggle raw text view">Raw</button>'
230
+ f'<button type="button" class="copy-raw-btn" title="Copy raw content">Copy</button>'
231
+ f"</div>"
232
+ f"</div>"
233
+ f'<div class="message-content assistant-message">'
234
+ f'<div class="assistant-rendered markdown-content markdown-body" data-raw="{encoded}">'
235
+ f'<noscript><pre style="white-space: pre-wrap;">{encoded}</pre></noscript>'
236
+ f"</div>"
237
+ f'<pre class="assistant-raw">{encoded}</pre>'
238
+ f"</div>"
239
+ f"</div>"
240
+ )
241
+
242
+
243
+ def _try_render_todo_args(arguments: str) -> str | None:
244
+ try:
245
+ parsed = json.loads(arguments)
246
+ if not isinstance(parsed, dict) or "todos" not in parsed or not isinstance(parsed["todos"], list):
247
+ return None
248
+
249
+ todos = cast(list[dict[str, str]], parsed["todos"])
250
+ if not todos:
251
+ return None
252
+
253
+ items_html: list[str] = []
254
+ for todo in todos:
255
+ content = _escape_html(todo.get("content", ""))
256
+ status = todo.get("status", "pending")
257
+ status_class = f"status-{status}"
258
+
259
+ items_html.append(
260
+ f'<div class="todo-item {status_class}">'
261
+ f'<span class="todo-bullet">●</span>'
262
+ f'<span class="todo-content">{content}</span>'
263
+ f"</div>"
264
+ )
265
+
266
+ if not items_html:
267
+ return None
268
+
269
+ return f'<div class="todo-list">{"".join(items_html)}</div>'
270
+ except Exception:
271
+ return None
272
+
273
+
274
+ def _render_sub_agent_result(content: str) -> str:
275
+ encoded = _escape_html(content)
276
+ return (
277
+ f'<div class="subagent-result-container">'
278
+ f'<div class="subagent-toolbar">'
279
+ f'<button type="button" class="raw-toggle" aria-pressed="false" title="Toggle raw text view">Raw</button>'
280
+ f'<button type="button" class="copy-raw-btn" title="Copy raw content">Copy</button>'
281
+ f"</div>"
282
+ f'<div class="subagent-content">'
283
+ f'<div class="subagent-rendered markdown-content markdown-body" data-raw="{encoded}">'
284
+ f'<noscript><pre style="white-space: pre-wrap;">{encoded}</pre></noscript>'
285
+ f"</div>"
286
+ f'<pre class="subagent-raw">{encoded}</pre>'
287
+ f"</div>"
288
+ f"</div>"
289
+ )
290
+
291
+
292
+ def _render_text_block(text: str) -> str:
293
+ lines = text.splitlines()
294
+ escaped_lines = [_escape_html(line) for line in lines]
295
+
296
+ if len(lines) <= _TOOL_OUTPUT_PREVIEW_LINES:
297
+ content = "\n".join(escaped_lines)
298
+ return f'<div style="white-space: pre-wrap; font-family: var(--font-mono);">{content}</div>'
299
+
300
+ preview = "\n".join(escaped_lines[:_TOOL_OUTPUT_PREVIEW_LINES])
301
+ full = "\n".join(escaped_lines)
302
+
303
+ return (
304
+ f'<div class="expandable-output expandable">'
305
+ f'<div class="preview-text" style="white-space: pre-wrap; font-family: var(--font-mono);">{preview}</div>'
306
+ f'<div class="expand-hint expand-text">click to expand full output ({len(lines)} lines)</div>'
307
+ f'<div class="full-text" style="white-space: pre-wrap; font-family: var(--font-mono);">{full}</div>'
308
+ f'<div class="collapse-hint">click to collapse</div>'
309
+ f"</div>"
310
+ )
311
+
312
+
313
+ _COLLAPSIBLE_LINE_THRESHOLD: Final[int] = 100
314
+ _COLLAPSIBLE_CHAR_THRESHOLD: Final[int] = 10000
315
+
316
+
317
+ def _should_collapse(text: str) -> bool:
318
+ """Check if content should be collapsed (over 100 lines or 10000 chars)."""
319
+ return text.count("\n") + 1 > _COLLAPSIBLE_LINE_THRESHOLD or len(text) > _COLLAPSIBLE_CHAR_THRESHOLD
320
+
321
+
322
+ def _render_diff_block(diff: str) -> str:
323
+ lines = diff.splitlines()
324
+ rendered: list[str] = []
325
+ for line in lines:
326
+ escaped = _escape_html(line)
327
+ if line.startswith("+"):
328
+ rendered.append(f'<span class="diff-line diff-plus">{escaped}</span>')
329
+ elif line.startswith("-"):
330
+ rendered.append(f'<span class="diff-line diff-minus">{escaped}</span>')
331
+ else:
332
+ rendered.append(f'<span class="diff-line diff-ctx">{escaped}</span>')
333
+ diff_content = f'<div class="diff-view">{"".join(rendered)}</div>'
334
+ open_attr = "" if _should_collapse(diff) else " open"
335
+ return (
336
+ f'<details class="diff-collapsible"{open_attr}>'
337
+ f"<summary>Diff ({len(lines)} lines)</summary>"
338
+ f"{diff_content}"
339
+ "</details>"
340
+ )
341
+
342
+
343
+ def _get_diff_text(ui_extra: model.ToolResultUIExtra | None) -> str | None:
344
+ if ui_extra is None:
345
+ return None
346
+ if ui_extra.type != model.ToolResultUIExtraType.DIFF_TEXT:
347
+ return None
348
+ return ui_extra.diff_text
349
+
350
+
351
+ def _get_mermaid_link_html(
352
+ ui_extra: model.ToolResultUIExtra | None, tool_call: model.ToolCallItem | None = None
353
+ ) -> str | None:
354
+ if tool_call and tool_call.name == "Mermaid":
355
+ try:
356
+ args = json.loads(tool_call.arguments)
357
+ code = args.get("code", "")
358
+ except Exception:
359
+ code = ""
360
+ else:
361
+ code = ""
362
+
363
+ if not code and (
364
+ ui_extra is None or ui_extra.type != model.ToolResultUIExtraType.MERMAID_LINK or not ui_extra.mermaid_link
365
+ ):
366
+ return None
367
+
368
+ # Prepare code for rendering and copy
369
+ escaped_code = _escape_html(code) if code else ""
370
+ line_count = code.count("\n") + 1 if code else 0
371
+
372
+ # Build Toolbar
373
+ toolbar_items: list[str] = []
374
+
375
+ if line_count > 0:
376
+ toolbar_items.append(f"<span>Lines: {line_count}</span>")
377
+
378
+ buttons_html: list[str] = []
379
+ if code:
380
+ buttons_html.append(
381
+ f'<button type="button" class="copy-mermaid-btn" data-code="{escaped_code}" title="Copy Mermaid Code">Copy Code</button>'
382
+ )
383
+
384
+ link = (
385
+ ui_extra.mermaid_link.link
386
+ if (ui_extra and ui_extra.type == model.ToolResultUIExtraType.MERMAID_LINK and ui_extra.mermaid_link)
387
+ else None
388
+ )
389
+
390
+ if link:
391
+ link_url = _escape_html(link)
392
+ buttons_html.append(
393
+ f'<a href="{link_url}" target="_blank" rel="noopener noreferrer" style="color: var(--accent); text-decoration: underline; margin-left: 8px;">View Online</a>'
394
+ )
395
+
396
+ toolbar_items.append(f"<div>{''.join(buttons_html)}</div>")
397
+
398
+ toolbar_html = (
399
+ '<div style="display: flex; justify-content: space-between; align-items: center; font-family: var(--font-mono); margin-top: 8px; padding-top: 8px; border-top: 1px dashed var(--border);">'
400
+ f"{''.join(toolbar_items)}"
401
+ "</div>"
402
+ )
403
+
404
+ # If we have code, render the diagram
405
+ if code:
406
+ return (
407
+ f'<div style="background: white; padding: 16px; border-radius: 4px; margin-top: 8px; border: 1px solid var(--border);">'
408
+ f'<div class="mermaid">{escaped_code}</div>'
409
+ f"{toolbar_html}"
410
+ f"</div>"
411
+ )
412
+
413
+ # Fallback to just link/toolbar if no code available (legacy support behavior)
414
+ return toolbar_html
415
+
416
+
417
+ def _format_tool_call(tool_call: model.ToolCallItem, result: model.ToolResultItem | None) -> str:
418
+ args_html = None
419
+ is_todo_list = False
420
+ ts_str = _format_msg_timestamp(tool_call.created_at)
421
+
422
+ if tool_call.name == "TodoWrite":
423
+ args_html = _try_render_todo_args(tool_call.arguments)
424
+ if args_html:
425
+ is_todo_list = True
426
+
427
+ if args_html is None:
428
+ try:
429
+ parsed = json.loads(tool_call.arguments)
430
+ args_text = json.dumps(parsed, ensure_ascii=False, indent=2)
431
+ except Exception:
432
+ args_text = tool_call.arguments
433
+
434
+ args_html = _escape_html(args_text or "")
435
+
436
+ if not args_html:
437
+ args_html = '<span style="color: var(--text-dim); font-style: italic;">(no arguments)</span>'
438
+
439
+ # Wrap tool-args with collapsible details element (except for TodoWrite which renders as a list)
440
+ if is_todo_list:
441
+ args_section = f'<div class="tool-args">{args_html}</div>'
442
+ else:
443
+ # Always collapse Mermaid, Edit, Write tools by default
444
+ always_collapse_tools = {"Mermaid", "Edit", "Write"}
445
+ force_collapse = tool_call.name in always_collapse_tools
446
+
447
+ # Collapse Memory tool for write operations
448
+ if tool_call.name == "Memory":
449
+ try:
450
+ parsed_args = json.loads(tool_call.arguments)
451
+ if parsed_args.get("command") in {"create", "str_replace", "insert"}:
452
+ force_collapse = True
453
+ except Exception:
454
+ pass
455
+
456
+ should_collapse = force_collapse or _should_collapse(args_html)
457
+ open_attr = "" if should_collapse else " open"
458
+ args_section = (
459
+ f'<details class="tool-args-collapsible"{open_attr}>'
460
+ "<summary>Arguments</summary>"
461
+ f'<div class="tool-args-content">{args_html}</div>'
462
+ "</details>"
463
+ )
464
+
465
+ html_parts = [
466
+ '<div class="tool-call">',
467
+ '<div class="tool-header">',
468
+ f'<span class="tool-name">{_escape_html(tool_call.name)}</span>',
469
+ '<div class="tool-header-right">',
470
+ f'<span class="tool-id">{_escape_html(tool_call.call_id)}</span>',
471
+ f'<span class="timestamp">{ts_str}</span>',
472
+ "</div>",
473
+ "</div>",
474
+ args_section,
475
+ ]
476
+
477
+ if result:
478
+ diff_text = _get_diff_text(result.ui_extra)
479
+ mermaid_html = _get_mermaid_link_html(result.ui_extra, tool_call)
480
+
481
+ should_hide_text = tool_call.name == "TodoWrite" and result.status != "error"
482
+
483
+ if tool_call.name == "Edit" and not diff_text and result.status != "error":
484
+ try:
485
+ args_data = json.loads(tool_call.arguments)
486
+ old_string = args_data.get("old_string", "")
487
+ new_string = args_data.get("new_string", "")
488
+ if old_string == "" and new_string:
489
+ diff_text = "\n".join(f"+{line}" for line in new_string.splitlines())
490
+ except Exception:
491
+ pass
492
+
493
+ items_to_render: list[str] = []
494
+
495
+ if result.output and not should_hide_text:
496
+ if is_sub_agent_tool(tool_call.name):
497
+ items_to_render.append(_render_sub_agent_result(result.output))
498
+ else:
499
+ items_to_render.append(_render_text_block(result.output))
500
+
501
+ if diff_text:
502
+ items_to_render.append(_render_diff_block(diff_text))
503
+
504
+ if mermaid_html:
505
+ items_to_render.append(mermaid_html)
506
+
507
+ if not items_to_render and not result.output and not should_hide_text:
508
+ items_to_render.append('<div style="color: var(--text-dim); font-style: italic;">(empty output)</div>')
509
+
510
+ if items_to_render:
511
+ status_class = result.status if result.status in ("success", "error") else "success"
512
+ html_parts.append(f'<div class="tool-result {status_class}">')
513
+ html_parts.extend(items_to_render)
514
+ html_parts.append("</div>")
515
+ else:
516
+ html_parts.append('<div class="tool-result pending">Executing...</div>')
517
+
518
+ html_parts.append("</div>")
519
+ return "".join(html_parts)
520
+
521
+
522
+ def _build_messages_html(
523
+ history: list[model.ConversationItem],
524
+ tool_results: dict[str, model.ToolResultItem],
525
+ ) -> str:
526
+ blocks: list[str] = []
527
+ assistant_counter = 0
528
+
529
+ renderable_items = [
530
+ item for item in history if not isinstance(item, (model.ToolResultItem, model.ReasoningEncryptedItem))
531
+ ]
532
+
533
+ for i, item in enumerate(renderable_items):
534
+ if isinstance(item, model.UserMessageItem):
535
+ text = _escape_html(item.content or "")
536
+ ts_str = _format_msg_timestamp(item.created_at)
537
+ blocks.append(
538
+ f'<div class="message-group">'
539
+ f'<div class="role-label user">'
540
+ f"User"
541
+ f'<span class="timestamp">{ts_str}</span>'
542
+ f"</div>"
543
+ f'<div class="message-content user" style="white-space: pre-wrap;">{text}</div>'
544
+ f"</div>"
545
+ )
546
+ elif isinstance(item, model.ReasoningTextItem):
547
+ text = _escape_html(item.content.strip())
548
+ blocks.append(f'<div class="thinking-block markdown-body markdown-content" data-raw="{text}"></div>')
549
+ elif isinstance(item, model.AssistantMessageItem):
550
+ assistant_counter += 1
551
+ blocks.append(_render_assistant_message(assistant_counter, item.content or "", item.created_at))
552
+ elif isinstance(item, model.ResponseMetadataItem):
553
+ blocks.append(_render_metadata_item(item))
554
+ elif isinstance(item, model.DeveloperMessageItem):
555
+ content = _escape_html(item.content or "")
556
+ ts_str = _format_msg_timestamp(item.created_at)
557
+
558
+ next_item = renderable_items[i + 1] if i + 1 < len(renderable_items) else None
559
+ extra_class = ""
560
+ if isinstance(next_item, (model.UserMessageItem, model.AssistantMessageItem)):
561
+ extra_class = " gap-below"
562
+
563
+ blocks.append(
564
+ f'<details class="developer-message{extra_class}">'
565
+ f"<summary>"
566
+ f"Developer"
567
+ f'<span class="timestamp">{ts_str}</span>'
568
+ f"</summary>"
569
+ f'<div class="details-content" style="white-space: pre-wrap;">{content}</div>'
570
+ f"</details>"
571
+ )
572
+
573
+ elif isinstance(item, model.ToolCallItem):
574
+ result = tool_results.get(item.call_id)
575
+ blocks.append(_format_tool_call(item, result))
576
+
577
+ return "\n".join(blocks)
578
+
579
+
580
+ def build_export_html(
581
+ session: Session,
582
+ system_prompt: str,
583
+ tools: list[llm_param.ToolSchema],
584
+ model_name: str,
585
+ ) -> str:
586
+ """Build HTML export for a session.
587
+
588
+ Args:
589
+ session: The session to export.
590
+ system_prompt: The system prompt used.
591
+ tools: List of tools available in the session.
592
+ model_name: The model name used.
593
+
594
+ Returns:
595
+ Complete HTML document as a string.
596
+ """
597
+ history = session.conversation_history
598
+ tool_results = {item.call_id: item for item in history if isinstance(item, model.ToolResultItem)}
599
+ messages_html = _build_messages_html(history, tool_results)
600
+ if not messages_html:
601
+ messages_html = '<div class="text-dim p-4 italic">No messages recorded for this session yet.</div>'
602
+
603
+ tools_html = _build_tools_html(tools)
604
+ session_id = session.id
605
+ session_updated = _format_timestamp(session.updated_at)
606
+ work_dir = _shorten_path(str(session.work_dir))
607
+ total_messages = len([item for item in history if not isinstance(item, model.ToolResultItem)])
608
+ footer_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
609
+ first_user_message = get_first_user_message(history)
610
+
611
+ template = Template(_load_template())
612
+ return template.substitute(
613
+ session_id=_escape_html(session_id),
614
+ model_name=_escape_html(model_name),
615
+ session_updated=_escape_html(session_updated),
616
+ work_dir=_escape_html(work_dir),
617
+ work_dir_full=_escape_html(str(session.work_dir)),
618
+ system_prompt=_escape_html(system_prompt),
619
+ tools_html=tools_html,
620
+ messages_html=messages_html,
621
+ footer_time=_escape_html(footer_time),
622
+ total_messages=total_messages,
623
+ first_user_message=_escape_html(first_user_message),
624
+ )