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.
- klaude_code/__init__.py +0 -0
- klaude_code/cli/__init__.py +1 -0
- klaude_code/cli/main.py +298 -0
- klaude_code/cli/runtime.py +331 -0
- klaude_code/cli/session_cmd.py +80 -0
- klaude_code/command/__init__.py +43 -0
- klaude_code/command/clear_cmd.py +20 -0
- klaude_code/command/command_abc.py +92 -0
- klaude_code/command/diff_cmd.py +138 -0
- klaude_code/command/export_cmd.py +86 -0
- klaude_code/command/help_cmd.py +51 -0
- klaude_code/command/model_cmd.py +43 -0
- klaude_code/command/prompt-dev-docs-update.md +56 -0
- klaude_code/command/prompt-dev-docs.md +46 -0
- klaude_code/command/prompt-init.md +45 -0
- klaude_code/command/prompt_command.py +69 -0
- klaude_code/command/refresh_cmd.py +43 -0
- klaude_code/command/registry.py +110 -0
- klaude_code/command/status_cmd.py +111 -0
- klaude_code/command/terminal_setup_cmd.py +252 -0
- klaude_code/config/__init__.py +11 -0
- klaude_code/config/config.py +177 -0
- klaude_code/config/list_model.py +162 -0
- klaude_code/config/select_model.py +67 -0
- klaude_code/const/__init__.py +133 -0
- klaude_code/core/__init__.py +0 -0
- klaude_code/core/agent.py +165 -0
- klaude_code/core/executor.py +485 -0
- klaude_code/core/manager/__init__.py +19 -0
- klaude_code/core/manager/agent_manager.py +127 -0
- klaude_code/core/manager/llm_clients.py +42 -0
- klaude_code/core/manager/llm_clients_builder.py +49 -0
- klaude_code/core/manager/sub_agent_manager.py +86 -0
- klaude_code/core/prompt.py +89 -0
- klaude_code/core/prompts/prompt-claude-code.md +98 -0
- klaude_code/core/prompts/prompt-codex.md +331 -0
- klaude_code/core/prompts/prompt-gemini.md +43 -0
- klaude_code/core/prompts/prompt-subagent-explore.md +27 -0
- klaude_code/core/prompts/prompt-subagent-oracle.md +23 -0
- klaude_code/core/prompts/prompt-subagent-webfetch.md +46 -0
- klaude_code/core/prompts/prompt-subagent.md +8 -0
- klaude_code/core/reminders.py +445 -0
- klaude_code/core/task.py +237 -0
- klaude_code/core/tool/__init__.py +75 -0
- klaude_code/core/tool/file/__init__.py +0 -0
- klaude_code/core/tool/file/apply_patch.py +492 -0
- klaude_code/core/tool/file/apply_patch_tool.md +1 -0
- klaude_code/core/tool/file/apply_patch_tool.py +204 -0
- klaude_code/core/tool/file/edit_tool.md +9 -0
- klaude_code/core/tool/file/edit_tool.py +274 -0
- klaude_code/core/tool/file/multi_edit_tool.md +42 -0
- klaude_code/core/tool/file/multi_edit_tool.py +199 -0
- klaude_code/core/tool/file/read_tool.md +14 -0
- klaude_code/core/tool/file/read_tool.py +326 -0
- klaude_code/core/tool/file/write_tool.md +8 -0
- klaude_code/core/tool/file/write_tool.py +146 -0
- klaude_code/core/tool/memory/__init__.py +0 -0
- klaude_code/core/tool/memory/memory_tool.md +16 -0
- klaude_code/core/tool/memory/memory_tool.py +462 -0
- klaude_code/core/tool/memory/skill_loader.py +245 -0
- klaude_code/core/tool/memory/skill_tool.md +24 -0
- klaude_code/core/tool/memory/skill_tool.py +97 -0
- klaude_code/core/tool/shell/__init__.py +0 -0
- klaude_code/core/tool/shell/bash_tool.md +43 -0
- klaude_code/core/tool/shell/bash_tool.py +123 -0
- klaude_code/core/tool/shell/command_safety.py +363 -0
- klaude_code/core/tool/sub_agent_tool.py +83 -0
- klaude_code/core/tool/todo/__init__.py +0 -0
- klaude_code/core/tool/todo/todo_write_tool.md +182 -0
- klaude_code/core/tool/todo/todo_write_tool.py +121 -0
- klaude_code/core/tool/todo/update_plan_tool.md +3 -0
- klaude_code/core/tool/todo/update_plan_tool.py +104 -0
- klaude_code/core/tool/tool_abc.py +25 -0
- klaude_code/core/tool/tool_context.py +106 -0
- klaude_code/core/tool/tool_registry.py +78 -0
- klaude_code/core/tool/tool_runner.py +252 -0
- klaude_code/core/tool/truncation.py +170 -0
- klaude_code/core/tool/web/__init__.py +0 -0
- klaude_code/core/tool/web/mermaid_tool.md +21 -0
- klaude_code/core/tool/web/mermaid_tool.py +76 -0
- klaude_code/core/tool/web/web_fetch_tool.md +8 -0
- klaude_code/core/tool/web/web_fetch_tool.py +159 -0
- klaude_code/core/turn.py +220 -0
- klaude_code/llm/__init__.py +21 -0
- klaude_code/llm/anthropic/__init__.py +3 -0
- klaude_code/llm/anthropic/client.py +221 -0
- klaude_code/llm/anthropic/input.py +200 -0
- klaude_code/llm/client.py +49 -0
- klaude_code/llm/input_common.py +239 -0
- klaude_code/llm/openai_compatible/__init__.py +3 -0
- klaude_code/llm/openai_compatible/client.py +211 -0
- klaude_code/llm/openai_compatible/input.py +109 -0
- klaude_code/llm/openai_compatible/tool_call_accumulator.py +80 -0
- klaude_code/llm/openrouter/__init__.py +3 -0
- klaude_code/llm/openrouter/client.py +200 -0
- klaude_code/llm/openrouter/input.py +160 -0
- klaude_code/llm/openrouter/reasoning_handler.py +209 -0
- klaude_code/llm/registry.py +22 -0
- klaude_code/llm/responses/__init__.py +3 -0
- klaude_code/llm/responses/client.py +216 -0
- klaude_code/llm/responses/input.py +167 -0
- klaude_code/llm/usage.py +109 -0
- klaude_code/protocol/__init__.py +4 -0
- klaude_code/protocol/commands.py +21 -0
- klaude_code/protocol/events.py +163 -0
- klaude_code/protocol/llm_param.py +147 -0
- klaude_code/protocol/model.py +287 -0
- klaude_code/protocol/op.py +89 -0
- klaude_code/protocol/op_handler.py +28 -0
- klaude_code/protocol/sub_agent.py +348 -0
- klaude_code/protocol/tools.py +15 -0
- klaude_code/session/__init__.py +4 -0
- klaude_code/session/export.py +624 -0
- klaude_code/session/selector.py +76 -0
- klaude_code/session/session.py +474 -0
- klaude_code/session/templates/export_session.html +1434 -0
- klaude_code/trace/__init__.py +3 -0
- klaude_code/trace/log.py +168 -0
- klaude_code/ui/__init__.py +91 -0
- klaude_code/ui/core/__init__.py +1 -0
- klaude_code/ui/core/display.py +103 -0
- klaude_code/ui/core/input.py +71 -0
- klaude_code/ui/core/stage_manager.py +55 -0
- klaude_code/ui/modes/__init__.py +1 -0
- klaude_code/ui/modes/debug/__init__.py +1 -0
- klaude_code/ui/modes/debug/display.py +36 -0
- klaude_code/ui/modes/exec/__init__.py +1 -0
- klaude_code/ui/modes/exec/display.py +63 -0
- klaude_code/ui/modes/repl/__init__.py +51 -0
- klaude_code/ui/modes/repl/clipboard.py +152 -0
- klaude_code/ui/modes/repl/completers.py +429 -0
- klaude_code/ui/modes/repl/display.py +60 -0
- klaude_code/ui/modes/repl/event_handler.py +375 -0
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +198 -0
- klaude_code/ui/modes/repl/key_bindings.py +170 -0
- klaude_code/ui/modes/repl/renderer.py +281 -0
- klaude_code/ui/renderers/__init__.py +0 -0
- klaude_code/ui/renderers/assistant.py +21 -0
- klaude_code/ui/renderers/common.py +8 -0
- klaude_code/ui/renderers/developer.py +158 -0
- klaude_code/ui/renderers/diffs.py +215 -0
- klaude_code/ui/renderers/errors.py +16 -0
- klaude_code/ui/renderers/metadata.py +190 -0
- klaude_code/ui/renderers/sub_agent.py +71 -0
- klaude_code/ui/renderers/thinking.py +39 -0
- klaude_code/ui/renderers/tools.py +551 -0
- klaude_code/ui/renderers/user_input.py +65 -0
- klaude_code/ui/rich/__init__.py +1 -0
- klaude_code/ui/rich/live.py +65 -0
- klaude_code/ui/rich/markdown.py +308 -0
- klaude_code/ui/rich/quote.py +34 -0
- klaude_code/ui/rich/searchable_text.py +71 -0
- klaude_code/ui/rich/status.py +240 -0
- klaude_code/ui/rich/theme.py +274 -0
- klaude_code/ui/terminal/__init__.py +1 -0
- klaude_code/ui/terminal/color.py +244 -0
- klaude_code/ui/terminal/control.py +147 -0
- klaude_code/ui/terminal/notifier.py +107 -0
- klaude_code/ui/terminal/progress_bar.py +87 -0
- klaude_code/ui/utils/__init__.py +1 -0
- klaude_code/ui/utils/common.py +108 -0
- klaude_code/ui/utils/debouncer.py +42 -0
- klaude_code/version.py +163 -0
- klaude_code-1.2.6.dist-info/METADATA +178 -0
- klaude_code-1.2.6.dist-info/RECORD +167 -0
- klaude_code-1.2.6.dist-info/WHEEL +4 -0
- 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
|
+
)
|