klaude-code 1.2.6__py3-none-any.whl → 1.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. klaude_code/auth/__init__.py +24 -0
  2. klaude_code/auth/codex/__init__.py +20 -0
  3. klaude_code/auth/codex/exceptions.py +17 -0
  4. klaude_code/auth/codex/jwt_utils.py +45 -0
  5. klaude_code/auth/codex/oauth.py +229 -0
  6. klaude_code/auth/codex/token_manager.py +84 -0
  7. klaude_code/cli/auth_cmd.py +73 -0
  8. klaude_code/cli/config_cmd.py +91 -0
  9. klaude_code/cli/cost_cmd.py +338 -0
  10. klaude_code/cli/debug.py +78 -0
  11. klaude_code/cli/list_model.py +307 -0
  12. klaude_code/cli/main.py +233 -134
  13. klaude_code/cli/runtime.py +309 -117
  14. klaude_code/{version.py → cli/self_update.py} +114 -5
  15. klaude_code/cli/session_cmd.py +37 -21
  16. klaude_code/command/__init__.py +88 -27
  17. klaude_code/command/clear_cmd.py +8 -7
  18. klaude_code/command/command_abc.py +31 -31
  19. klaude_code/command/debug_cmd.py +79 -0
  20. klaude_code/command/export_cmd.py +19 -53
  21. klaude_code/command/export_online_cmd.py +154 -0
  22. klaude_code/command/fork_session_cmd.py +267 -0
  23. klaude_code/command/help_cmd.py +7 -8
  24. klaude_code/command/model_cmd.py +60 -10
  25. klaude_code/command/model_select.py +84 -0
  26. klaude_code/command/prompt-jj-describe.md +32 -0
  27. klaude_code/command/prompt_command.py +19 -11
  28. klaude_code/command/refresh_cmd.py +8 -10
  29. klaude_code/command/registry.py +139 -40
  30. klaude_code/command/release_notes_cmd.py +84 -0
  31. klaude_code/command/resume_cmd.py +111 -0
  32. klaude_code/command/status_cmd.py +104 -60
  33. klaude_code/command/terminal_setup_cmd.py +7 -9
  34. klaude_code/command/thinking_cmd.py +98 -0
  35. klaude_code/config/__init__.py +14 -6
  36. klaude_code/config/assets/__init__.py +1 -0
  37. klaude_code/config/assets/builtin_config.yaml +303 -0
  38. klaude_code/config/builtin_config.py +38 -0
  39. klaude_code/config/config.py +378 -109
  40. klaude_code/config/select_model.py +117 -53
  41. klaude_code/config/thinking.py +269 -0
  42. klaude_code/{const/__init__.py → const.py} +50 -19
  43. klaude_code/core/agent.py +20 -28
  44. klaude_code/core/executor.py +327 -112
  45. klaude_code/core/manager/__init__.py +2 -4
  46. klaude_code/core/manager/llm_clients.py +1 -15
  47. klaude_code/core/manager/llm_clients_builder.py +10 -11
  48. klaude_code/core/manager/sub_agent_manager.py +37 -6
  49. klaude_code/core/prompt.py +63 -44
  50. klaude_code/core/prompts/prompt-claude-code.md +2 -13
  51. klaude_code/core/prompts/prompt-codex-gpt-5-1-codex-max.md +117 -0
  52. klaude_code/core/prompts/prompt-codex-gpt-5-2-codex.md +117 -0
  53. klaude_code/core/prompts/prompt-codex.md +9 -42
  54. klaude_code/core/prompts/prompt-minimal.md +12 -0
  55. klaude_code/core/prompts/{prompt-subagent-explore.md → prompt-sub-agent-explore.md} +16 -3
  56. klaude_code/core/prompts/{prompt-subagent-oracle.md → prompt-sub-agent-oracle.md} +1 -2
  57. klaude_code/core/prompts/prompt-sub-agent-web.md +51 -0
  58. klaude_code/core/reminders.py +283 -95
  59. klaude_code/core/task.py +113 -75
  60. klaude_code/core/tool/__init__.py +24 -31
  61. klaude_code/core/tool/file/_utils.py +36 -0
  62. klaude_code/core/tool/file/apply_patch.py +17 -25
  63. klaude_code/core/tool/file/apply_patch_tool.py +57 -77
  64. klaude_code/core/tool/file/diff_builder.py +151 -0
  65. klaude_code/core/tool/file/edit_tool.py +50 -63
  66. klaude_code/core/tool/file/move_tool.md +41 -0
  67. klaude_code/core/tool/file/move_tool.py +435 -0
  68. klaude_code/core/tool/file/read_tool.md +1 -1
  69. klaude_code/core/tool/file/read_tool.py +86 -86
  70. klaude_code/core/tool/file/write_tool.py +59 -69
  71. klaude_code/core/tool/report_back_tool.py +84 -0
  72. klaude_code/core/tool/shell/bash_tool.py +265 -22
  73. klaude_code/core/tool/shell/command_safety.py +3 -6
  74. klaude_code/core/tool/{memory → skill}/skill_tool.py +16 -26
  75. klaude_code/core/tool/sub_agent_tool.py +13 -2
  76. klaude_code/core/tool/todo/todo_write_tool.md +0 -157
  77. klaude_code/core/tool/todo/todo_write_tool.py +1 -1
  78. klaude_code/core/tool/todo/todo_write_tool_raw.md +182 -0
  79. klaude_code/core/tool/todo/update_plan_tool.py +1 -1
  80. klaude_code/core/tool/tool_abc.py +18 -0
  81. klaude_code/core/tool/tool_context.py +27 -12
  82. klaude_code/core/tool/tool_registry.py +7 -7
  83. klaude_code/core/tool/tool_runner.py +44 -36
  84. klaude_code/core/tool/truncation.py +29 -14
  85. klaude_code/core/tool/web/mermaid_tool.md +43 -0
  86. klaude_code/core/tool/web/mermaid_tool.py +2 -5
  87. klaude_code/core/tool/web/web_fetch_tool.md +1 -1
  88. klaude_code/core/tool/web/web_fetch_tool.py +112 -22
  89. klaude_code/core/tool/web/web_search_tool.md +23 -0
  90. klaude_code/core/tool/web/web_search_tool.py +130 -0
  91. klaude_code/core/turn.py +168 -66
  92. klaude_code/llm/__init__.py +2 -10
  93. klaude_code/llm/anthropic/client.py +190 -178
  94. klaude_code/llm/anthropic/input.py +39 -15
  95. klaude_code/llm/bedrock/__init__.py +3 -0
  96. klaude_code/llm/bedrock/client.py +60 -0
  97. klaude_code/llm/client.py +7 -21
  98. klaude_code/llm/codex/__init__.py +5 -0
  99. klaude_code/llm/codex/client.py +149 -0
  100. klaude_code/llm/google/__init__.py +3 -0
  101. klaude_code/llm/google/client.py +309 -0
  102. klaude_code/llm/google/input.py +215 -0
  103. klaude_code/llm/input_common.py +3 -9
  104. klaude_code/llm/openai_compatible/client.py +72 -164
  105. klaude_code/llm/openai_compatible/input.py +6 -4
  106. klaude_code/llm/openai_compatible/stream.py +273 -0
  107. klaude_code/llm/openai_compatible/tool_call_accumulator.py +17 -1
  108. klaude_code/llm/openrouter/client.py +89 -160
  109. klaude_code/llm/openrouter/input.py +18 -30
  110. klaude_code/llm/openrouter/reasoning.py +118 -0
  111. klaude_code/llm/registry.py +39 -7
  112. klaude_code/llm/responses/client.py +184 -171
  113. klaude_code/llm/responses/input.py +20 -1
  114. klaude_code/llm/usage.py +17 -12
  115. klaude_code/protocol/commands.py +17 -1
  116. klaude_code/protocol/events.py +31 -4
  117. klaude_code/protocol/llm_param.py +13 -10
  118. klaude_code/protocol/model.py +232 -29
  119. klaude_code/protocol/op.py +90 -1
  120. klaude_code/protocol/op_handler.py +35 -1
  121. klaude_code/protocol/sub_agent/__init__.py +117 -0
  122. klaude_code/protocol/sub_agent/explore.py +63 -0
  123. klaude_code/protocol/sub_agent/oracle.py +91 -0
  124. klaude_code/protocol/sub_agent/task.py +61 -0
  125. klaude_code/protocol/sub_agent/web.py +79 -0
  126. klaude_code/protocol/tools.py +4 -2
  127. klaude_code/session/__init__.py +2 -2
  128. klaude_code/session/codec.py +71 -0
  129. klaude_code/session/export.py +293 -86
  130. klaude_code/session/selector.py +89 -67
  131. klaude_code/session/session.py +320 -309
  132. klaude_code/session/store.py +220 -0
  133. klaude_code/session/templates/export_session.html +595 -83
  134. klaude_code/session/templates/mermaid_viewer.html +926 -0
  135. klaude_code/skill/__init__.py +27 -0
  136. klaude_code/skill/assets/deslop/SKILL.md +17 -0
  137. klaude_code/skill/assets/dev-docs/SKILL.md +108 -0
  138. klaude_code/skill/assets/handoff/SKILL.md +39 -0
  139. klaude_code/skill/assets/jj-workspace/SKILL.md +20 -0
  140. klaude_code/skill/assets/skill-creator/SKILL.md +139 -0
  141. klaude_code/{core/tool/memory/skill_loader.py → skill/loader.py} +55 -15
  142. klaude_code/skill/manager.py +70 -0
  143. klaude_code/skill/system_skills.py +192 -0
  144. klaude_code/trace/__init__.py +20 -2
  145. klaude_code/trace/log.py +150 -5
  146. klaude_code/ui/__init__.py +4 -9
  147. klaude_code/ui/core/input.py +1 -1
  148. klaude_code/ui/core/stage_manager.py +7 -7
  149. klaude_code/ui/modes/debug/display.py +2 -1
  150. klaude_code/ui/modes/repl/__init__.py +3 -48
  151. klaude_code/ui/modes/repl/clipboard.py +5 -5
  152. klaude_code/ui/modes/repl/completers.py +487 -123
  153. klaude_code/ui/modes/repl/display.py +5 -4
  154. klaude_code/ui/modes/repl/event_handler.py +370 -117
  155. klaude_code/ui/modes/repl/input_prompt_toolkit.py +552 -105
  156. klaude_code/ui/modes/repl/key_bindings.py +146 -23
  157. klaude_code/ui/modes/repl/renderer.py +189 -99
  158. klaude_code/ui/renderers/assistant.py +9 -2
  159. klaude_code/ui/renderers/bash_syntax.py +178 -0
  160. klaude_code/ui/renderers/common.py +78 -0
  161. klaude_code/ui/renderers/developer.py +104 -48
  162. klaude_code/ui/renderers/diffs.py +87 -6
  163. klaude_code/ui/renderers/errors.py +11 -6
  164. klaude_code/ui/renderers/mermaid_viewer.py +57 -0
  165. klaude_code/ui/renderers/metadata.py +112 -76
  166. klaude_code/ui/renderers/sub_agent.py +92 -7
  167. klaude_code/ui/renderers/thinking.py +40 -18
  168. klaude_code/ui/renderers/tools.py +405 -227
  169. klaude_code/ui/renderers/user_input.py +73 -13
  170. klaude_code/ui/rich/__init__.py +10 -1
  171. klaude_code/ui/rich/cjk_wrap.py +228 -0
  172. klaude_code/ui/rich/code_panel.py +131 -0
  173. klaude_code/ui/rich/live.py +17 -0
  174. klaude_code/ui/rich/markdown.py +305 -170
  175. klaude_code/ui/rich/searchable_text.py +10 -13
  176. klaude_code/ui/rich/status.py +190 -49
  177. klaude_code/ui/rich/theme.py +135 -39
  178. klaude_code/ui/terminal/__init__.py +55 -0
  179. klaude_code/ui/terminal/color.py +1 -1
  180. klaude_code/ui/terminal/control.py +13 -22
  181. klaude_code/ui/terminal/notifier.py +44 -4
  182. klaude_code/ui/terminal/selector.py +658 -0
  183. klaude_code/ui/utils/common.py +0 -18
  184. klaude_code-1.8.0.dist-info/METADATA +377 -0
  185. klaude_code-1.8.0.dist-info/RECORD +219 -0
  186. {klaude_code-1.2.6.dist-info → klaude_code-1.8.0.dist-info}/entry_points.txt +1 -0
  187. klaude_code/command/diff_cmd.py +0 -138
  188. klaude_code/command/prompt-dev-docs-update.md +0 -56
  189. klaude_code/command/prompt-dev-docs.md +0 -46
  190. klaude_code/config/list_model.py +0 -162
  191. klaude_code/core/manager/agent_manager.py +0 -127
  192. klaude_code/core/prompts/prompt-subagent-webfetch.md +0 -46
  193. klaude_code/core/tool/file/multi_edit_tool.md +0 -42
  194. klaude_code/core/tool/file/multi_edit_tool.py +0 -199
  195. klaude_code/core/tool/memory/memory_tool.md +0 -16
  196. klaude_code/core/tool/memory/memory_tool.py +0 -462
  197. klaude_code/llm/openrouter/reasoning_handler.py +0 -209
  198. klaude_code/protocol/sub_agent.py +0 -348
  199. klaude_code/ui/utils/debouncer.py +0 -42
  200. klaude_code-1.2.6.dist-info/METADATA +0 -178
  201. klaude_code-1.2.6.dist-info/RECORD +0 -167
  202. /klaude_code/core/prompts/{prompt-subagent.md → prompt-sub-agent.md} +0 -0
  203. /klaude_code/core/tool/{memory → skill}/__init__.py +0 -0
  204. /klaude_code/core/tool/{memory → skill}/skill_tool.md +0 -0
  205. {klaude_code-1.2.6.dist-info → klaude_code-1.8.0.dist-info}/WHEEL +0 -0
@@ -63,7 +63,7 @@ def get_default_export_path(session: Session) -> Path:
63
63
  """Get default export path for a session."""
64
64
  from klaude_code.session.session import Session as SessionClass
65
65
 
66
- exports_dir = SessionClass._exports_dir() # pyright: ignore[reportPrivateUsage]
66
+ exports_dir = SessionClass.exports_dir()
67
67
  timestamp = datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
68
68
  first_msg = get_first_user_message(session.conversation_history)
69
69
  sanitized_msg = _sanitize_filename(first_msg)
@@ -154,67 +154,94 @@ def _format_token_count(count: int) -> str:
154
154
  return f"{m}M" if rem == 0 else f"{m}M{rem}k"
155
155
 
156
156
 
157
- def _format_cost(cost: float) -> str:
158
- return f"${cost:.4f}"
157
+ def _format_cost(cost: float, currency: str = "USD") -> str:
158
+ symbol = "¥" if currency == "CNY" else "$"
159
+ return f"{symbol}{cost:.4f}"
159
160
 
160
161
 
161
- def _render_metadata_item(item: model.ResponseMetadataItem) -> str:
162
- # Model Name [@ Provider]
162
+ def _render_single_metadata(
163
+ metadata: model.TaskMetadata,
164
+ *,
165
+ indent: int = 0,
166
+ show_context: bool = True,
167
+ ) -> str:
168
+ """Render a single TaskMetadata block as HTML.
169
+
170
+ Args:
171
+ metadata: The TaskMetadata to render.
172
+ indent: Number of spaces to indent (0 for main, 2 for sub-agents).
173
+ show_context: Whether to show context usage percent.
174
+
175
+ Returns:
176
+ HTML string for this metadata block.
177
+ """
163
178
  parts: list[str] = []
164
179
 
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(" ", "-"))
180
+ # Model Name [@ Provider]
181
+ model_parts = [f'<span class="metadata-model">{_escape_html(metadata.model_name)}</span>']
182
+ if metadata.provider:
183
+ provider = _escape_html(metadata.provider.lower().replace(" ", "-"))
168
184
  model_parts.append(f'<span class="metadata-provider">@{provider}</span>')
169
185
 
170
186
  parts.append("".join(model_parts))
171
187
 
172
188
  # Stats
173
- if item.usage:
174
- u = item.usage
189
+ if metadata.usage:
190
+ u = metadata.usage
175
191
  # Input with cost
176
192
  input_stat = f"input: {_format_token_count(u.input_tokens)}"
177
193
  if u.input_cost is not None:
178
- input_stat += f"({_format_cost(u.input_cost)})"
194
+ input_stat += f"({_format_cost(u.input_cost, u.currency)})"
179
195
  parts.append(f'<span class="metadata-stat">{input_stat}</span>')
180
196
 
181
197
  # Cached with cost
182
198
  if u.cached_tokens > 0:
183
199
  cached_stat = f"cached: {_format_token_count(u.cached_tokens)}"
184
200
  if u.cache_read_cost is not None:
185
- cached_stat += f"({_format_cost(u.cache_read_cost)})"
201
+ cached_stat += f"({_format_cost(u.cache_read_cost, u.currency)})"
186
202
  parts.append(f'<span class="metadata-stat">{cached_stat}</span>')
187
203
 
188
204
  # Output with cost
189
205
  output_stat = f"output: {_format_token_count(u.output_tokens)}"
190
206
  if u.output_cost is not None:
191
- output_stat += f"({_format_cost(u.output_cost)})"
207
+ output_stat += f"({_format_cost(u.output_cost, u.currency)})"
192
208
  parts.append(f'<span class="metadata-stat">{output_stat}</span>')
193
209
 
194
210
  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:
211
+ parts.append(f'<span class="metadata-stat">thinking: {_format_token_count(u.reasoning_tokens)}</span>')
212
+ if show_context and u.context_usage_percent is not None:
199
213
  parts.append(f'<span class="metadata-stat">context: {u.context_usage_percent:.1f}%</span>')
200
214
  if u.throughput_tps is not None:
201
215
  parts.append(f'<span class="metadata-stat">tps: {u.throughput_tps:.1f}</span>')
202
216
 
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>')
217
+ if metadata.task_duration_s is not None:
218
+ parts.append(f'<span class="metadata-stat">time: {metadata.task_duration_s:.1f}s</span>')
205
219
 
206
220
  # 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>')
221
+ if metadata.usage is not None and metadata.usage.total_cost is not None:
222
+ parts.append(
223
+ f'<span class="metadata-stat">cost: {_format_cost(metadata.usage.total_cost, metadata.usage.currency)}</span>'
224
+ )
209
225
 
210
226
  divider = '<span class="metadata-divider">/</span>'
211
227
  joined_html = divider.join(parts)
212
228
 
213
- return (
214
- f'<div class="response-metadata">'
215
- f'<div class="metadata-line">{joined_html}</div>'
216
- f"</div>"
217
- )
229
+ indent_style = f' style="padding-left: {indent}em;"' if indent > 0 else ""
230
+ return f'<div class="metadata-line"{indent_style}>{joined_html}</div>'
231
+
232
+
233
+ def _render_metadata_item(item: model.TaskMetadataItem) -> str:
234
+ """Render TaskMetadataItem including main agent and sub-agents."""
235
+ lines: list[str] = []
236
+
237
+ # Main agent metadata
238
+ lines.append(_render_single_metadata(item.main_agent, indent=0, show_context=True))
239
+
240
+ # Sub-agent metadata with indent
241
+ for sub in item.sub_agent_task_metadata:
242
+ lines.append(_render_single_metadata(sub, indent=1, show_context=False))
243
+
244
+ return f'<div class="response-metadata">{"".join(lines)}</div>'
218
245
 
219
246
 
220
247
  def _render_assistant_message(index: int, content: str, timestamp: datetime) -> str:
@@ -240,20 +267,30 @@ def _render_assistant_message(index: int, content: str, timestamp: datetime) ->
240
267
  )
241
268
 
242
269
 
243
- def _try_render_todo_args(arguments: str) -> str | None:
270
+ def _try_render_todo_args(arguments: str, tool_name: str) -> str | None:
244
271
  try:
245
272
  parsed = json.loads(arguments)
246
- if not isinstance(parsed, dict) or "todos" not in parsed or not isinstance(parsed["todos"], list):
273
+ if not isinstance(parsed, dict):
247
274
  return None
248
275
 
249
- todos = cast(list[dict[str, str]], parsed["todos"])
250
- if not todos:
276
+ # Support both TodoWrite (todos/content) and update_plan (plan/step)
277
+ parsed_dict = cast(dict[str, Any], parsed)
278
+ if tool_name == "TodoWrite":
279
+ items = parsed_dict.get("todos")
280
+ content_key = "content"
281
+ elif tool_name == "update_plan":
282
+ items = parsed_dict.get("plan")
283
+ content_key = "step"
284
+ else:
285
+ return None
286
+
287
+ if not isinstance(items, list) or not items:
251
288
  return None
252
289
 
253
290
  items_html: list[str] = []
254
- for todo in todos:
255
- content = _escape_html(todo.get("content", ""))
256
- status = todo.get("status", "pending")
291
+ for item in cast(list[dict[str, str]], items):
292
+ content = _escape_html(item.get(content_key, ""))
293
+ status = item.get("status", "pending")
257
294
  status_class = f"status-{status}"
258
295
 
259
296
  items_html.append(
@@ -267,23 +304,33 @@ def _try_render_todo_args(arguments: str) -> str | None:
267
304
  return None
268
305
 
269
306
  return f'<div class="todo-list">{"".join(items_html)}</div>'
270
- except Exception:
307
+ except (json.JSONDecodeError, KeyError, TypeError):
271
308
  return None
272
309
 
273
310
 
274
- def _render_sub_agent_result(content: str) -> str:
275
- encoded = _escape_html(content)
311
+ def _render_sub_agent_result(content: str, description: str | None = None) -> str:
312
+ # Try to format as JSON for better readability
313
+ try:
314
+ parsed = json.loads(content)
315
+ formatted = "```json\n" + json.dumps(parsed, ensure_ascii=False, indent=2) + "\n```"
316
+ except (json.JSONDecodeError, TypeError):
317
+ formatted = content
318
+
319
+ if description:
320
+ formatted = f"# {description}\n\n{formatted}"
321
+
322
+ encoded = _escape_html(formatted)
276
323
  return (
277
- f'<div class="subagent-result-container">'
278
- f'<div class="subagent-toolbar">'
324
+ f'<div class="sub-agent-result-container">'
325
+ f'<div class="sub-agent-toolbar">'
279
326
  f'<button type="button" class="raw-toggle" aria-pressed="false" title="Toggle raw text view">Raw</button>'
280
327
  f'<button type="button" class="copy-raw-btn" title="Copy raw content">Copy</button>'
281
328
  f"</div>"
282
- f'<div class="subagent-content">'
283
- f'<div class="subagent-rendered markdown-content markdown-body" data-raw="{encoded}">'
329
+ f'<div class="sub-agent-content">'
330
+ f'<div class="sub-agent-rendered markdown-content markdown-body" data-raw="{encoded}">'
284
331
  f'<noscript><pre style="white-space: pre-wrap;">{encoded}</pre></noscript>'
285
332
  f"</div>"
286
- f'<pre class="subagent-raw">{encoded}</pre>'
333
+ f'<pre class="sub-agent-raw">{encoded}</pre>'
287
334
  f"</div>"
288
335
  f"</div>"
289
336
  )
@@ -319,55 +366,141 @@ def _should_collapse(text: str) -> bool:
319
366
  return text.count("\n") + 1 > _COLLAPSIBLE_LINE_THRESHOLD or len(text) > _COLLAPSIBLE_CHAR_THRESHOLD
320
367
 
321
368
 
322
- def _render_diff_block(diff: str) -> str:
323
- lines = diff.splitlines()
369
+ def _render_diff_block(diff: model.DiffUIExtra) -> str:
324
370
  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>')
371
+ line_count = 0
372
+
373
+ for file_diff in diff.files:
374
+ header = _render_diff_file_header(file_diff)
375
+ if header:
376
+ rendered.append(header)
377
+ for line in file_diff.lines:
378
+ rendered.append(_render_diff_line(line))
379
+ line_count += 1
380
+
381
+ if line_count == 0:
382
+ rendered.append('<span class="diff-line diff-ctx">&nbsp;</span>')
383
+
333
384
  diff_content = f'<div class="diff-view">{"".join(rendered)}</div>'
334
- open_attr = "" if _should_collapse(diff) else " open"
385
+ open_attr = "" if _should_collapse("\n" * max(1, line_count)) else " open"
335
386
  return (
336
387
  f'<details class="diff-collapsible"{open_attr}>'
337
- f"<summary>Diff ({len(lines)} lines)</summary>"
388
+ f"<summary>Diff ({line_count} lines)</summary>"
338
389
  f"{diff_content}"
339
390
  "</details>"
340
391
  )
341
392
 
342
393
 
343
- def _get_diff_text(ui_extra: model.ToolResultUIExtra | None) -> str | None:
394
+ def _render_diff_file_header(file_diff: model.DiffFileDiff) -> str:
395
+ stats_parts: list[str] = []
396
+ if file_diff.stats_add > 0:
397
+ stats_parts.append(f'<span class="diff-stats-add">+{file_diff.stats_add}</span>')
398
+ if file_diff.stats_remove > 0:
399
+ stats_parts.append(f'<span class="diff-stats-remove">-{file_diff.stats_remove}</span>')
400
+ stats_html = f' <span class="diff-stats">{" ".join(stats_parts)}</span>' if stats_parts else ""
401
+ file_name = _escape_html(file_diff.file_path)
402
+ return f'<div class="diff-file">{file_name}{stats_html}</div>'
403
+
404
+
405
+ def _render_diff_line(line: model.DiffLine) -> str:
406
+ if line.kind == "gap":
407
+ line_class = "diff-ctx"
408
+ prefix = "⋮"
409
+ else:
410
+ line_class = "diff-plus" if line.kind == "add" else "diff-minus" if line.kind == "remove" else "diff-ctx"
411
+ prefix = "+" if line.kind == "add" else "-" if line.kind == "remove" else " "
412
+ spans = [_render_diff_span(span, line.kind) for span in line.spans]
413
+ content = "".join(spans)
414
+ if not content:
415
+ content = "&nbsp;"
416
+ return f'<span class="diff-line {line_class}">{prefix} {content}</span>'
417
+
418
+
419
+ def _render_diff_span(span: model.DiffSpan, line_kind: str) -> str:
420
+ text = _escape_html(span.text)
421
+ if line_kind == "add" and span.op == "insert":
422
+ return f'<span class="diff-span diff-char-add">{text}</span>'
423
+ if line_kind == "remove" and span.op == "delete":
424
+ return f'<span class="diff-span diff-char-remove">{text}</span>'
425
+ return f'<span class="diff-span">{text}</span>'
426
+
427
+
428
+ def _render_markdown_doc(doc: model.MarkdownDocUIExtra) -> str:
429
+ encoded = _escape_html(doc.content)
430
+ file_path = _escape_html(doc.file_path)
431
+ header = f'<div class="diff-file">{file_path} <span style="font-weight: normal; color: var(--text-dim); font-size: 12px; margin-left: 8px;">(markdown content)</span></div>'
432
+
433
+ # Using a container that mimics diff-view but for markdown
434
+ content = (
435
+ f'<div class="markdown-content markdown-body" data-raw="{encoded}" '
436
+ f'style="padding: 12px; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--bg-body); margin-top: 4px;">'
437
+ f'<noscript><pre style="white-space: pre-wrap;">{encoded}</pre></noscript>'
438
+ f"</div>"
439
+ )
440
+
441
+ line_count = doc.content.count("\n") + 1
442
+ open_attr = " open"
443
+
444
+ return (
445
+ f'<details class="diff-collapsible"{open_attr}>'
446
+ f"<summary>File Content ({line_count} lines)</summary>"
447
+ f'<div style="margin-top: 8px;">'
448
+ f"{header}"
449
+ f"{content}"
450
+ f"</div>"
451
+ f"</details>"
452
+ )
453
+
454
+
455
+ def _collect_ui_extras(ui_extra: model.ToolResultUIExtra | None) -> list[model.ToolResultUIExtra]:
344
456
  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
457
+ return []
458
+ if isinstance(ui_extra, model.MultiUIExtra):
459
+ return list(ui_extra.items)
460
+ return [ui_extra]
461
+
462
+
463
+ def _build_add_only_diff(text: str, file_path: str) -> model.DiffUIExtra:
464
+ lines: list[model.DiffLine] = []
465
+ new_line_no = 1
466
+ for line in text.splitlines():
467
+ lines.append(
468
+ model.DiffLine(
469
+ kind="add",
470
+ new_line_no=new_line_no,
471
+ spans=[model.DiffSpan(op="equal", text=line)],
472
+ )
473
+ )
474
+ new_line_no += 1
475
+ file_diff = model.DiffFileDiff(file_path=file_path, lines=lines, stats_add=len(lines), stats_remove=0)
476
+ return model.DiffUIExtra(files=[file_diff])
349
477
 
350
478
 
351
479
  def _get_mermaid_link_html(
352
480
  ui_extra: model.ToolResultUIExtra | None, tool_call: model.ToolCallItem | None = None
353
481
  ) -> str | None:
354
- if tool_call and tool_call.name == "Mermaid":
482
+ code = ""
483
+ link: str | None = None
484
+ line_count = 0
485
+
486
+ if isinstance(ui_extra, model.MermaidLinkUIExtra):
487
+ code = ui_extra.code
488
+ link = ui_extra.link
489
+ line_count = ui_extra.line_count
490
+
491
+ if not code and tool_call and tool_call.name == "Mermaid":
355
492
  try:
356
493
  args = json.loads(tool_call.arguments)
357
494
  code = args.get("code", "")
358
- except Exception:
495
+ except (json.JSONDecodeError, TypeError):
359
496
  code = ""
360
- else:
361
- code = ""
497
+ line_count = code.count("\n") + 1 if code else 0
362
498
 
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
- ):
499
+ if not code and not link:
366
500
  return None
367
501
 
368
502
  # Prepare code for rendering and copy
369
503
  escaped_code = _escape_html(code) if code else ""
370
- line_count = code.count("\n") + 1 if code else 0
371
504
 
372
505
  # Build Toolbar
373
506
  toolbar_items: list[str] = []
@@ -380,12 +513,9 @@ def _get_mermaid_link_html(
380
513
  buttons_html.append(
381
514
  f'<button type="button" class="copy-mermaid-btn" data-code="{escaped_code}" title="Copy Mermaid Code">Copy Code</button>'
382
515
  )
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
- )
516
+ buttons_html.append(
517
+ '<button type="button" class="fullscreen-mermaid-btn" title="View Fullscreen">Fullscreen</button>'
518
+ )
389
519
 
390
520
  if link:
391
521
  link_url = _escape_html(link)
@@ -419,8 +549,8 @@ def _format_tool_call(tool_call: model.ToolCallItem, result: model.ToolResultIte
419
549
  is_todo_list = False
420
550
  ts_str = _format_msg_timestamp(tool_call.created_at)
421
551
 
422
- if tool_call.name == "TodoWrite":
423
- args_html = _try_render_todo_args(tool_call.arguments)
552
+ if tool_call.name in ("TodoWrite", "update_plan"):
553
+ args_html = _try_render_todo_args(tool_call.arguments, tool_call.name)
424
554
  if args_html:
425
555
  is_todo_list = True
426
556
 
@@ -428,7 +558,7 @@ def _format_tool_call(tool_call: model.ToolCallItem, result: model.ToolResultIte
428
558
  try:
429
559
  parsed = json.loads(tool_call.arguments)
430
560
  args_text = json.dumps(parsed, ensure_ascii=False, indent=2)
431
- except Exception:
561
+ except (json.JSONDecodeError, TypeError):
432
562
  args_text = tool_call.arguments
433
563
 
434
564
  args_html = _escape_html(args_text or "")
@@ -450,7 +580,7 @@ def _format_tool_call(tool_call: model.ToolCallItem, result: model.ToolResultIte
450
580
  parsed_args = json.loads(tool_call.arguments)
451
581
  if parsed_args.get("command") in {"create", "str_replace", "insert"}:
452
582
  force_collapse = True
453
- except Exception:
583
+ except (json.JSONDecodeError, TypeError):
454
584
  pass
455
585
 
456
586
  should_collapse = force_collapse or _should_collapse(args_html)
@@ -475,31 +605,50 @@ def _format_tool_call(tool_call: model.ToolCallItem, result: model.ToolResultIte
475
605
  ]
476
606
 
477
607
  if result:
478
- diff_text = _get_diff_text(result.ui_extra)
479
- mermaid_html = _get_mermaid_link_html(result.ui_extra, tool_call)
608
+ extras = _collect_ui_extras(result.ui_extra)
480
609
 
481
- should_hide_text = tool_call.name == "TodoWrite" and result.status != "error"
610
+ mermaid_extra = next((x for x in extras if isinstance(x, model.MermaidLinkUIExtra)), None)
611
+ mermaid_source = mermaid_extra if mermaid_extra else result.ui_extra
612
+ mermaid_html = _get_mermaid_link_html(mermaid_source, tool_call)
482
613
 
483
- if tool_call.name == "Edit" and not diff_text and result.status != "error":
614
+ should_hide_text = tool_call.name in ("TodoWrite", "update_plan") and result.status != "error"
615
+
616
+ if (
617
+ tool_call.name == "Edit"
618
+ and not any(isinstance(x, model.DiffUIExtra) for x in extras)
619
+ and result.status != "error"
620
+ ):
484
621
  try:
485
622
  args_data = json.loads(tool_call.arguments)
623
+ file_path = args_data.get("file_path", "Unknown file")
486
624
  old_string = args_data.get("old_string", "")
487
625
  new_string = args_data.get("new_string", "")
488
626
  if old_string == "" and new_string:
489
- diff_text = "\n".join(f"+{line}" for line in new_string.splitlines())
490
- except Exception:
627
+ extras.append(_build_add_only_diff(new_string, file_path))
628
+ except (json.JSONDecodeError, TypeError):
491
629
  pass
492
630
 
493
631
  items_to_render: list[str] = []
494
632
 
495
633
  if result.output and not should_hide_text:
496
634
  if is_sub_agent_tool(tool_call.name):
497
- items_to_render.append(_render_sub_agent_result(result.output))
635
+ description = None
636
+ try:
637
+ args = json.loads(tool_call.arguments)
638
+ if isinstance(args, dict):
639
+ typed_args = cast(dict[str, Any], args)
640
+ description = cast(str | None, typed_args.get("description"))
641
+ except (json.JSONDecodeError, TypeError):
642
+ pass
643
+ items_to_render.append(_render_sub_agent_result(result.output, description))
498
644
  else:
499
645
  items_to_render.append(_render_text_block(result.output))
500
646
 
501
- if diff_text:
502
- items_to_render.append(_render_diff_block(diff_text))
647
+ for extra in extras:
648
+ if isinstance(extra, model.DiffUIExtra):
649
+ items_to_render.append(_render_diff_block(extra))
650
+ elif isinstance(extra, model.MarkdownDocUIExtra):
651
+ items_to_render.append(_render_markdown_doc(extra))
503
652
 
504
653
  if mermaid_html:
505
654
  items_to_render.append(mermaid_html)
@@ -522,7 +671,13 @@ def _format_tool_call(tool_call: model.ToolCallItem, result: model.ToolResultIte
522
671
  def _build_messages_html(
523
672
  history: list[model.ConversationItem],
524
673
  tool_results: dict[str, model.ToolResultItem],
674
+ *,
675
+ seen_session_ids: set[str] | None = None,
676
+ nesting_level: int = 0,
525
677
  ) -> str:
678
+ if seen_session_ids is None:
679
+ seen_session_ids = set()
680
+
526
681
  blocks: list[str] = []
527
682
  assistant_counter = 0
528
683
 
@@ -549,7 +704,7 @@ def _build_messages_html(
549
704
  elif isinstance(item, model.AssistantMessageItem):
550
705
  assistant_counter += 1
551
706
  blocks.append(_render_assistant_message(assistant_counter, item.content or "", item.created_at))
552
- elif isinstance(item, model.ResponseMetadataItem):
707
+ elif isinstance(item, model.TaskMetadataItem):
553
708
  blocks.append(_render_metadata_item(item))
554
709
  elif isinstance(item, model.DeveloperMessageItem):
555
710
  content = _escape_html(item.content or "")
@@ -574,9 +729,61 @@ def _build_messages_html(
574
729
  result = tool_results.get(item.call_id)
575
730
  blocks.append(_format_tool_call(item, result))
576
731
 
732
+ # Recursively render sub-agent session history
733
+ if result is not None:
734
+ sub_agent_html = _render_sub_agent_session(result, seen_session_ids, nesting_level)
735
+ if sub_agent_html:
736
+ blocks.append(sub_agent_html)
737
+
577
738
  return "\n".join(blocks)
578
739
 
579
740
 
741
+ def _render_sub_agent_session(
742
+ tool_result: model.ToolResultItem,
743
+ seen_session_ids: set[str],
744
+ nesting_level: int,
745
+ ) -> str | None:
746
+ """Render sub-agent session history when a tool result references it."""
747
+ from klaude_code.session.session import Session
748
+
749
+ ui_extra = tool_result.ui_extra
750
+ if not isinstance(ui_extra, model.SessionIdUIExtra):
751
+ return None
752
+
753
+ session_id = ui_extra.session_id
754
+ if not session_id or session_id in seen_session_ids:
755
+ return None
756
+
757
+ seen_session_ids.add(session_id)
758
+
759
+ try:
760
+ sub_session = Session.load(session_id)
761
+ except (OSError, json.JSONDecodeError, ValueError):
762
+ return None
763
+
764
+ sub_history = sub_session.conversation_history
765
+ sub_tool_results = {item.call_id: item for item in sub_history if isinstance(item, model.ToolResultItem)}
766
+
767
+ sub_html = _build_messages_html(
768
+ sub_history,
769
+ sub_tool_results,
770
+ seen_session_ids=seen_session_ids,
771
+ nesting_level=nesting_level + 1,
772
+ )
773
+
774
+ if not sub_html:
775
+ return None
776
+
777
+ # Wrap in a collapsible sub-agent container using same style as other collapsible sections
778
+ indent_style = f' style="margin-left: {nesting_level * 16}px;"' if nesting_level > 0 else ""
779
+ return (
780
+ f'<details class="sub-agent-session"{indent_style}>'
781
+ f"<summary>Sub-agent: {_escape_html(session_id)}</summary>"
782
+ f'<div class="sub-agent-content">{sub_html}</div>'
783
+ f"</details>"
784
+ )
785
+
786
+
580
787
  def build_export_html(
581
788
  session: Session,
582
789
  system_prompt: str,