klaude-code 1.2.1__py3-none-any.whl → 1.2.3__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 (140) hide show
  1. klaude_code/cli/main.py +9 -4
  2. klaude_code/cli/runtime.py +42 -43
  3. klaude_code/command/__init__.py +7 -5
  4. klaude_code/command/clear_cmd.py +6 -29
  5. klaude_code/command/command_abc.py +44 -8
  6. klaude_code/command/diff_cmd.py +33 -27
  7. klaude_code/command/export_cmd.py +18 -26
  8. klaude_code/command/help_cmd.py +10 -8
  9. klaude_code/command/model_cmd.py +11 -40
  10. klaude_code/command/{prompt-update-dev-doc.md → prompt-dev-docs-update.md} +3 -2
  11. klaude_code/command/{prompt-dev-doc.md → prompt-dev-docs.md} +3 -2
  12. klaude_code/command/prompt-init.md +2 -5
  13. klaude_code/command/prompt_command.py +6 -6
  14. klaude_code/command/refresh_cmd.py +4 -5
  15. klaude_code/command/registry.py +16 -19
  16. klaude_code/command/terminal_setup_cmd.py +12 -11
  17. klaude_code/config/__init__.py +4 -0
  18. klaude_code/config/config.py +25 -26
  19. klaude_code/config/list_model.py +8 -3
  20. klaude_code/config/select_model.py +1 -1
  21. klaude_code/const/__init__.py +1 -1
  22. klaude_code/core/__init__.py +0 -3
  23. klaude_code/core/agent.py +25 -50
  24. klaude_code/core/executor.py +268 -101
  25. klaude_code/core/prompt.py +12 -12
  26. klaude_code/core/{prompt → prompts}/prompt-gemini.md +1 -1
  27. klaude_code/core/reminders.py +76 -95
  28. klaude_code/core/task.py +21 -14
  29. klaude_code/core/tool/__init__.py +45 -11
  30. klaude_code/core/tool/file/apply_patch.py +5 -1
  31. klaude_code/core/tool/file/apply_patch_tool.py +11 -13
  32. klaude_code/core/tool/file/edit_tool.py +27 -23
  33. klaude_code/core/tool/file/multi_edit_tool.py +15 -17
  34. klaude_code/core/tool/file/read_tool.py +41 -36
  35. klaude_code/core/tool/file/write_tool.py +13 -15
  36. klaude_code/core/tool/memory/memory_tool.py +85 -68
  37. klaude_code/core/tool/memory/skill_tool.py +10 -12
  38. klaude_code/core/tool/shell/bash_tool.py +24 -22
  39. klaude_code/core/tool/shell/command_safety.py +12 -1
  40. klaude_code/core/tool/sub_agent_tool.py +11 -12
  41. klaude_code/core/tool/todo/todo_write_tool.py +21 -28
  42. klaude_code/core/tool/todo/update_plan_tool.py +14 -24
  43. klaude_code/core/tool/tool_abc.py +3 -4
  44. klaude_code/core/tool/tool_context.py +7 -7
  45. klaude_code/core/tool/tool_registry.py +30 -47
  46. klaude_code/core/tool/tool_runner.py +35 -43
  47. klaude_code/core/tool/truncation.py +14 -20
  48. klaude_code/core/tool/web/mermaid_tool.py +12 -14
  49. klaude_code/core/tool/web/web_fetch_tool.py +15 -17
  50. klaude_code/core/turn.py +19 -7
  51. klaude_code/llm/__init__.py +3 -4
  52. klaude_code/llm/anthropic/client.py +30 -46
  53. klaude_code/llm/anthropic/input.py +4 -11
  54. klaude_code/llm/client.py +29 -8
  55. klaude_code/llm/input_common.py +66 -36
  56. klaude_code/llm/openai_compatible/client.py +42 -84
  57. klaude_code/llm/openai_compatible/input.py +11 -16
  58. klaude_code/llm/openai_compatible/tool_call_accumulator.py +2 -2
  59. klaude_code/llm/openrouter/client.py +40 -289
  60. klaude_code/llm/openrouter/input.py +13 -35
  61. klaude_code/llm/openrouter/reasoning_handler.py +209 -0
  62. klaude_code/llm/registry.py +5 -75
  63. klaude_code/llm/responses/client.py +34 -55
  64. klaude_code/llm/responses/input.py +24 -26
  65. klaude_code/llm/usage.py +109 -0
  66. klaude_code/protocol/__init__.py +4 -0
  67. klaude_code/protocol/events.py +3 -2
  68. klaude_code/protocol/{llm_parameter.py → llm_param.py} +12 -32
  69. klaude_code/protocol/model.py +49 -4
  70. klaude_code/protocol/op.py +18 -16
  71. klaude_code/protocol/op_handler.py +28 -0
  72. klaude_code/{core → protocol}/sub_agent.py +7 -0
  73. klaude_code/session/export.py +150 -70
  74. klaude_code/session/session.py +28 -14
  75. klaude_code/session/templates/export_session.html +180 -42
  76. klaude_code/trace/__init__.py +2 -2
  77. klaude_code/trace/log.py +11 -5
  78. klaude_code/ui/__init__.py +91 -8
  79. klaude_code/ui/core/__init__.py +1 -0
  80. klaude_code/ui/core/display.py +103 -0
  81. klaude_code/ui/core/input.py +71 -0
  82. klaude_code/ui/modes/__init__.py +1 -0
  83. klaude_code/ui/modes/debug/__init__.py +1 -0
  84. klaude_code/ui/{base/debug_event_display.py → modes/debug/display.py} +9 -5
  85. klaude_code/ui/modes/exec/__init__.py +1 -0
  86. klaude_code/ui/{base/exec_display.py → modes/exec/display.py} +28 -2
  87. klaude_code/ui/{repl → modes/repl}/__init__.py +5 -6
  88. klaude_code/ui/modes/repl/clipboard.py +152 -0
  89. klaude_code/ui/modes/repl/completers.py +429 -0
  90. klaude_code/ui/modes/repl/display.py +60 -0
  91. klaude_code/ui/modes/repl/event_handler.py +375 -0
  92. klaude_code/ui/modes/repl/input_prompt_toolkit.py +198 -0
  93. klaude_code/ui/modes/repl/key_bindings.py +170 -0
  94. klaude_code/ui/{repl → modes/repl}/renderer.py +109 -132
  95. klaude_code/ui/renderers/assistant.py +21 -0
  96. klaude_code/ui/renderers/common.py +0 -16
  97. klaude_code/ui/renderers/developer.py +18 -18
  98. klaude_code/ui/renderers/diffs.py +36 -14
  99. klaude_code/ui/renderers/errors.py +1 -1
  100. klaude_code/ui/renderers/metadata.py +50 -27
  101. klaude_code/ui/renderers/sub_agent.py +43 -9
  102. klaude_code/ui/renderers/thinking.py +33 -1
  103. klaude_code/ui/renderers/tools.py +212 -20
  104. klaude_code/ui/renderers/user_input.py +19 -23
  105. klaude_code/ui/rich/__init__.py +1 -0
  106. klaude_code/ui/{rich_ext → rich}/searchable_text.py +3 -1
  107. klaude_code/ui/{renderers → rich}/status.py +29 -18
  108. klaude_code/ui/{base → rich}/theme.py +8 -2
  109. klaude_code/ui/terminal/__init__.py +1 -0
  110. klaude_code/ui/{base/terminal_color.py → terminal/color.py} +4 -1
  111. klaude_code/ui/{base/terminal_control.py → terminal/control.py} +1 -0
  112. klaude_code/ui/{base/terminal_notifier.py → terminal/notifier.py} +5 -2
  113. klaude_code/ui/utils/__init__.py +1 -0
  114. klaude_code/ui/{base/utils.py → utils/common.py} +35 -3
  115. {klaude_code-1.2.1.dist-info → klaude_code-1.2.3.dist-info}/METADATA +1 -1
  116. klaude_code-1.2.3.dist-info/RECORD +161 -0
  117. klaude_code/core/clipboard_manifest.py +0 -124
  118. klaude_code/llm/openrouter/tool_call_accumulator.py +0 -80
  119. klaude_code/ui/base/__init__.py +0 -1
  120. klaude_code/ui/base/display_abc.py +0 -36
  121. klaude_code/ui/base/input_abc.py +0 -20
  122. klaude_code/ui/repl/display.py +0 -36
  123. klaude_code/ui/repl/event_handler.py +0 -247
  124. klaude_code/ui/repl/input.py +0 -773
  125. klaude_code/ui/rich_ext/__init__.py +0 -1
  126. klaude_code-1.2.1.dist-info/RECORD +0 -151
  127. /klaude_code/core/{prompt → prompts}/prompt-claude-code.md +0 -0
  128. /klaude_code/core/{prompt → prompts}/prompt-codex.md +0 -0
  129. /klaude_code/core/{prompt → prompts}/prompt-subagent-explore.md +0 -0
  130. /klaude_code/core/{prompt → prompts}/prompt-subagent-oracle.md +0 -0
  131. /klaude_code/core/{prompt → prompts}/prompt-subagent-webfetch.md +0 -0
  132. /klaude_code/core/{prompt → prompts}/prompt-subagent.md +0 -0
  133. /klaude_code/ui/{base → core}/stage_manager.py +0 -0
  134. /klaude_code/ui/{rich_ext → rich}/live.py +0 -0
  135. /klaude_code/ui/{rich_ext → rich}/markdown.py +0 -0
  136. /klaude_code/ui/{rich_ext → rich}/quote.py +0 -0
  137. /klaude_code/ui/{base → terminal}/progress_bar.py +0 -0
  138. /klaude_code/ui/{base → utils}/debouncer.py +0 -0
  139. {klaude_code-1.2.1.dist-info → klaude_code-1.2.3.dist-info}/WHEEL +0 -0
  140. {klaude_code-1.2.1.dist-info → klaude_code-1.2.3.dist-info}/entry_points.txt +0 -0
@@ -11,21 +11,8 @@ from pathlib import Path
11
11
  from string import Template
12
12
  from typing import TYPE_CHECKING, Any, Final, cast
13
13
 
14
- from klaude_code.core.sub_agent import is_sub_agent_tool
15
- from klaude_code.protocol.llm_parameter import ToolSchema
16
- from klaude_code.protocol.model import (
17
- AssistantMessageItem,
18
- ConversationItem,
19
- DeveloperMessageItem,
20
- ReasoningEncryptedItem,
21
- ReasoningTextItem,
22
- ResponseMetadataItem,
23
- ToolCallItem,
24
- ToolResultItem,
25
- ToolResultUIExtra,
26
- ToolResultUIExtraType,
27
- UserMessageItem,
28
- )
14
+ from klaude_code.protocol import llm_param, model
15
+ from klaude_code.protocol.sub_agent import is_sub_agent_tool
29
16
 
30
17
  if TYPE_CHECKING:
31
18
  from klaude_code.session.session import Session
@@ -58,10 +45,14 @@ def _format_timestamp(value: float | None) -> str:
58
45
  return datetime.fromtimestamp(value).strftime("%Y-%m-%d %H:%M:%S")
59
46
 
60
47
 
61
- def get_first_user_message(history: list[ConversationItem]) -> str:
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:
62
53
  """Extract the first user message content from conversation history."""
63
54
  for item in history:
64
- if isinstance(item, UserMessageItem) and item.content:
55
+ if isinstance(item, model.UserMessageItem) and item.content:
65
56
  content = item.content.strip()
66
57
  first_line = content.split("\n")[0]
67
58
  return first_line[:100] if len(first_line) > 100 else first_line
@@ -86,7 +77,7 @@ def _load_template() -> str:
86
77
  return template_file.read_text(encoding="utf-8")
87
78
 
88
79
 
89
- def _build_tools_html(tools: list[ToolSchema]) -> str:
80
+ def _build_tools_html(tools: list[llm_param.ToolSchema]) -> str:
90
81
  if not tools:
91
82
  return '<div style="padding: 12px; font-style: italic;">No tools registered for this session.</div>'
92
83
  chunks: list[str] = []
@@ -163,7 +154,11 @@ def _format_token_count(count: int) -> str:
163
154
  return f"{m}M" if rem == 0 else f"{m}M{rem}k"
164
155
 
165
156
 
166
- def _render_metadata_item(item: ResponseMetadataItem) -> str:
157
+ def _format_cost(cost: float) -> str:
158
+ return f"${cost:.4f}"
159
+
160
+
161
+ def _render_metadata_item(item: model.ResponseMetadataItem) -> str:
167
162
  # Line 1: Model Name [@ Provider]
168
163
  model_parts = [f'<span class="metadata-model">{_escape_html(item.model_name)}</span>']
169
164
  if item.provider:
@@ -176,10 +171,25 @@ def _render_metadata_item(item: ResponseMetadataItem) -> str:
176
171
  stats_parts: list[str] = []
177
172
  if item.usage:
178
173
  u = item.usage
179
- stats_parts.append(f'<span class="metadata-stat">input: {_format_token_count(u.input_tokens)}</span>')
174
+ # Input with cost
175
+ input_stat = f"input: {_format_token_count(u.input_tokens)}"
176
+ if u.input_cost is not None:
177
+ input_stat += f"({_format_cost(u.input_cost)})"
178
+ stats_parts.append(f'<span class="metadata-stat">{input_stat}</span>')
179
+
180
+ # Cached with cost
180
181
  if u.cached_tokens > 0:
181
- stats_parts.append(f'<span class="metadata-stat">cached: {_format_token_count(u.cached_tokens)}</span>')
182
- stats_parts.append(f'<span class="metadata-stat">output: {_format_token_count(u.output_tokens)}</span>')
182
+ cached_stat = f"cached: {_format_token_count(u.cached_tokens)}"
183
+ if u.cache_read_cost is not None:
184
+ cached_stat += f"({_format_cost(u.cache_read_cost)})"
185
+ stats_parts.append(f'<span class="metadata-stat">{cached_stat}</span>')
186
+
187
+ # Output with cost
188
+ output_stat = f"output: {_format_token_count(u.output_tokens)}"
189
+ if u.output_cost is not None:
190
+ output_stat += f"({_format_cost(u.output_cost)})"
191
+ stats_parts.append(f'<span class="metadata-stat">{output_stat}</span>')
192
+
183
193
  if u.reasoning_tokens > 0:
184
194
  stats_parts.append(
185
195
  f'<span class="metadata-stat">thinking: {_format_token_count(u.reasoning_tokens)}</span>'
@@ -190,7 +200,11 @@ def _render_metadata_item(item: ResponseMetadataItem) -> str:
190
200
  stats_parts.append(f'<span class="metadata-stat">tps: {u.throughput_tps:.1f}</span>')
191
201
 
192
202
  if item.task_duration_s is not None:
193
- stats_parts.append(f'<span class="metadata-stat">cost: {item.task_duration_s:.1f}s</span>')
203
+ stats_parts.append(f'<span class="metadata-stat">time: {item.task_duration_s:.1f}s</span>')
204
+
205
+ # Total cost
206
+ if item.usage is not None and item.usage.total_cost is not None:
207
+ stats_parts.append(f'<span class="metadata-stat">cost: {_format_cost(item.usage.total_cost)}</span>')
194
208
 
195
209
  stats_html = ""
196
210
  if stats_parts:
@@ -205,13 +219,15 @@ def _render_metadata_item(item: ResponseMetadataItem) -> str:
205
219
  )
206
220
 
207
221
 
208
- def _render_assistant_message(index: int, content: str) -> str:
222
+ def _render_assistant_message(index: int, content: str, timestamp: datetime) -> str:
209
223
  encoded = _escape_html(content)
224
+ ts_str = _format_msg_timestamp(timestamp)
210
225
  return (
211
226
  f'<div class="message-group assistant-message-group">'
212
227
  f'<div class="message-header">'
213
228
  f'<div class="role-label assistant">Assistant</div>'
214
229
  f'<div class="assistant-toolbar">'
230
+ f'<span class="timestamp">{ts_str}</span>'
215
231
  f'<button type="button" class="raw-toggle" aria-pressed="false" title="Toggle raw text view">Raw</button>'
216
232
  f'<button type="button" class="copy-raw-btn" title="Copy raw content">Copy</button>'
217
233
  f"</div>"
@@ -289,9 +305,9 @@ def _render_text_block(text: str) -> str:
289
305
  return (
290
306
  f'<div class="expandable-output expandable">'
291
307
  f'<div class="preview-text" style="white-space: pre-wrap; font-family: var(--font-mono);">{preview}</div>'
292
- f'<div class="expand-hint expand-text">Click to expand full output ({len(lines)} lines)</div>'
308
+ f'<div class="expand-hint expand-text">click to expand full output ({len(lines)} lines)</div>'
293
309
  f'<div class="full-text" style="white-space: pre-wrap; font-family: var(--font-mono);">{full}</div>'
294
- f'<div class="collapse-hint">Click to collapse</div>'
310
+ f'<div class="collapse-hint">click to collapse</div>'
295
311
  f"</div>"
296
312
  )
297
313
 
@@ -326,49 +342,85 @@ def _render_diff_block(diff: str) -> str:
326
342
  )
327
343
 
328
344
 
329
- def _get_diff_text(ui_extra: ToolResultUIExtra | None) -> str | None:
345
+ def _get_diff_text(ui_extra: model.ToolResultUIExtra | None) -> str | None:
330
346
  if ui_extra is None:
331
347
  return None
332
- if ui_extra.type != ToolResultUIExtraType.DIFF_TEXT:
348
+ if ui_extra.type != model.ToolResultUIExtraType.DIFF_TEXT:
333
349
  return None
334
350
  return ui_extra.diff_text
335
351
 
336
352
 
337
- def _get_mermaid_link_html(ui_extra: ToolResultUIExtra | None, tool_call: ToolCallItem | None = None) -> str | None:
338
- if ui_extra is None:
339
- return None
340
- if ui_extra.type != ToolResultUIExtraType.MERMAID_LINK:
341
- return None
342
- if ui_extra.mermaid_link is None or not ui_extra.mermaid_link.link:
343
- return None
344
- link = _escape_html(ui_extra.mermaid_link.link)
345
- lines = ui_extra.mermaid_link.line_count
346
-
347
- copy_btn = ""
353
+ def _get_mermaid_link_html(
354
+ ui_extra: model.ToolResultUIExtra | None, tool_call: model.ToolCallItem | None = None
355
+ ) -> str | None:
348
356
  if tool_call and tool_call.name == "Mermaid":
349
357
  try:
350
358
  args = json.loads(tool_call.arguments)
351
- code = args.get("code")
352
- if code:
353
- escaped_code = _escape_html(code)
354
- copy_btn = f'<button type="button" class="copy-mermaid-btn" data-code="{escaped_code}" title="Copy Mermaid Code">Copy Code</button>'
359
+ code = args.get("code", "")
355
360
  except Exception:
356
- pass
361
+ code = ""
362
+ else:
363
+ code = ""
357
364
 
358
- return (
359
- '<div style="display: flex; justify-content: space-between; align-items: center; font-family: var(--font-mono);">'
360
- f"<span>Lines: {lines}</span>"
361
- f"<div>"
362
- f"{copy_btn}"
363
- f'<a href="{link}" target="_blank" rel="noopener noreferrer" style="color: var(--accent); text-decoration: underline; margin-left: 8px;">View Diagram</a>'
364
- f"</div>"
365
+ if not code and (
366
+ ui_extra is None or ui_extra.type != model.ToolResultUIExtraType.MERMAID_LINK or not ui_extra.mermaid_link
367
+ ):
368
+ return None
369
+
370
+ # Prepare code for rendering and copy
371
+ escaped_code = _escape_html(code) if code else ""
372
+ line_count = code.count("\n") + 1 if code else 0
373
+
374
+ # Build Toolbar
375
+ toolbar_items: list[str] = []
376
+
377
+ if line_count > 0:
378
+ toolbar_items.append(f"<span>Lines: {line_count}</span>")
379
+
380
+ buttons_html: list[str] = []
381
+ if code:
382
+ buttons_html.append(
383
+ f'<button type="button" class="copy-mermaid-btn" data-code="{escaped_code}" title="Copy Mermaid Code">Copy Code</button>'
384
+ )
385
+
386
+ link = (
387
+ ui_extra.mermaid_link.link
388
+ if (ui_extra and ui_extra.type == model.ToolResultUIExtraType.MERMAID_LINK and ui_extra.mermaid_link)
389
+ else None
390
+ )
391
+
392
+ if link:
393
+ link_url = _escape_html(link)
394
+ buttons_html.append(
395
+ f'<a href="{link_url}" target="_blank" rel="noopener noreferrer" style="color: var(--accent); text-decoration: underline; margin-left: 8px;">View Online</a>'
396
+ )
397
+
398
+ toolbar_items.append(f"<div>{''.join(buttons_html)}</div>")
399
+
400
+ toolbar_html = (
401
+ '<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);">'
402
+ f"{''.join(toolbar_items)}"
365
403
  "</div>"
366
404
  )
367
405
 
406
+ # If we have code, render the diagram
407
+ if code:
408
+ return (
409
+ f'<div style="background: white; padding: 16px; border-radius: 4px; margin-top: 8px; border: 1px solid var(--border);">'
410
+ f'<div class="mermaid">{escaped_code}</div>'
411
+ f"{toolbar_html}"
412
+ f"</div>"
413
+ )
414
+
415
+ # Fallback to just link/toolbar if no code available (legacy support behavior)
416
+ return toolbar_html
417
+
368
418
 
369
- def _format_tool_call(tool_call: ToolCallItem, result: ToolResultItem | None) -> str:
419
+ def _format_tool_call(tool_call: model.ToolCallItem, result: model.ToolResultItem | None) -> str:
370
420
  args_html = None
371
421
  is_todo_list = False
422
+ ts_str = _format_msg_timestamp(tool_call.created_at)
423
+
372
424
  if tool_call.name == "TodoWrite":
373
425
  args_html = _try_render_todo_args(tool_call.arguments)
374
426
  if args_html:
@@ -390,7 +442,21 @@ def _format_tool_call(tool_call: ToolCallItem, result: ToolResultItem | None) ->
390
442
  if is_todo_list:
391
443
  args_section = f'<div class="tool-args">{args_html}</div>'
392
444
  else:
393
- open_attr = "" if _should_collapse(args_html) else " open"
445
+ # Always collapse Mermaid, Edit, Write tools by default
446
+ always_collapse_tools = {"Mermaid", "Edit", "Write"}
447
+ force_collapse = tool_call.name in always_collapse_tools
448
+
449
+ # Collapse Memory tool for write operations
450
+ if tool_call.name == "Memory":
451
+ try:
452
+ parsed_args = json.loads(tool_call.arguments)
453
+ if parsed_args.get("command") in {"create", "str_replace", "insert"}:
454
+ force_collapse = True
455
+ except Exception:
456
+ pass
457
+
458
+ should_collapse = force_collapse or _should_collapse(args_html)
459
+ open_attr = "" if should_collapse else " open"
394
460
  args_section = (
395
461
  f'<details class="tool-args-collapsible"{open_attr}>'
396
462
  "<summary>Arguments</summary>"
@@ -402,7 +468,10 @@ def _format_tool_call(tool_call: ToolCallItem, result: ToolResultItem | None) ->
402
468
  '<div class="tool-call">',
403
469
  '<div class="tool-header">',
404
470
  f'<span class="tool-name">{_escape_html(tool_call.name)}</span>',
471
+ '<div class="tool-header-right">',
405
472
  f'<span class="tool-id">{_escape_html(tool_call.call_id)}</span>',
473
+ f'<span class="timestamp">{ts_str}</span>',
474
+ "</div>",
406
475
  "</div>",
407
476
  args_section,
408
477
  ]
@@ -453,46 +522,57 @@ def _format_tool_call(tool_call: ToolCallItem, result: ToolResultItem | None) ->
453
522
 
454
523
 
455
524
  def _build_messages_html(
456
- history: list[ConversationItem],
457
- tool_results: dict[str, ToolResultItem],
525
+ history: list[model.ConversationItem],
526
+ tool_results: dict[str, model.ToolResultItem],
458
527
  ) -> str:
459
528
  blocks: list[str] = []
460
529
  assistant_counter = 0
461
530
 
462
- renderable_items = [item for item in history if not isinstance(item, (ToolResultItem, ReasoningEncryptedItem))]
531
+ renderable_items = [
532
+ item for item in history if not isinstance(item, (model.ToolResultItem, model.ReasoningEncryptedItem))
533
+ ]
463
534
 
464
535
  for i, item in enumerate(renderable_items):
465
- if isinstance(item, UserMessageItem):
536
+ if isinstance(item, model.UserMessageItem):
466
537
  text = _escape_html(item.content or "")
538
+ ts_str = _format_msg_timestamp(item.created_at)
467
539
  blocks.append(
468
540
  f'<div class="message-group">'
469
- f'<div class="role-label user">User</div>'
541
+ f'<div class="role-label user">'
542
+ f"User"
543
+ f'<span class="timestamp">{ts_str}</span>'
544
+ f"</div>"
470
545
  f'<div class="message-content user" style="white-space: pre-wrap;">{text}</div>'
471
546
  f"</div>"
472
547
  )
473
- elif isinstance(item, ReasoningTextItem):
548
+ elif isinstance(item, model.ReasoningTextItem):
474
549
  text = _escape_html(item.content.strip())
475
- blocks.append(f'<div class="thinking-block">{text.replace(chr(10), "<br>")}</div>')
476
- elif isinstance(item, AssistantMessageItem):
550
+ blocks.append(f'<div class="thinking-block markdown-body markdown-content" data-raw="{text}"></div>')
551
+ elif isinstance(item, model.AssistantMessageItem):
477
552
  assistant_counter += 1
478
- blocks.append(_render_assistant_message(assistant_counter, item.content or ""))
479
- elif isinstance(item, ResponseMetadataItem):
553
+ blocks.append(_render_assistant_message(assistant_counter, item.content or "", item.created_at))
554
+ elif isinstance(item, model.ResponseMetadataItem):
480
555
  blocks.append(_render_metadata_item(item))
481
- elif isinstance(item, DeveloperMessageItem):
556
+ elif isinstance(item, model.DeveloperMessageItem):
482
557
  content = _escape_html(item.content or "")
558
+ ts_str = _format_msg_timestamp(item.created_at)
483
559
 
484
560
  next_item = renderable_items[i + 1] if i + 1 < len(renderable_items) else None
485
561
  extra_class = ""
486
- if isinstance(next_item, (UserMessageItem, AssistantMessageItem)):
562
+ if isinstance(next_item, (model.UserMessageItem, model.AssistantMessageItem)):
487
563
  extra_class = " gap-below"
488
564
 
489
565
  blocks.append(
490
566
  f'<details class="developer-message{extra_class}">'
491
- f"<summary>Developer</summary>"
567
+ f"<summary>"
568
+ f"Developer"
569
+ f'<span class="timestamp">{ts_str}</span>'
570
+ f"</summary>"
492
571
  f'<div class="details-content" style="white-space: pre-wrap;">{content}</div>'
493
572
  f"</details>"
494
573
  )
495
- elif isinstance(item, ToolCallItem):
574
+
575
+ elif isinstance(item, model.ToolCallItem):
496
576
  result = tool_results.get(item.call_id)
497
577
  blocks.append(_format_tool_call(item, result))
498
578
 
@@ -502,7 +582,7 @@ def _build_messages_html(
502
582
  def build_export_html(
503
583
  session: Session,
504
584
  system_prompt: str,
505
- tools: list[ToolSchema],
585
+ tools: list[llm_param.ToolSchema],
506
586
  model_name: str,
507
587
  ) -> str:
508
588
  """Build HTML export for a session.
@@ -517,7 +597,7 @@ def build_export_html(
517
597
  Complete HTML document as a string.
518
598
  """
519
599
  history = session.conversation_history
520
- tool_results = {item.call_id: item for item in history if isinstance(item, ToolResultItem)}
600
+ tool_results = {item.call_id: item for item in history if isinstance(item, model.ToolResultItem)}
521
601
  messages_html = _build_messages_html(history, tool_results)
522
602
  if not messages_html:
523
603
  messages_html = '<div class="text-dim p-4 italic">No messages recorded for this session yet.</div>'
@@ -526,7 +606,7 @@ def build_export_html(
526
606
  session_id = session.id
527
607
  session_updated = _format_timestamp(session.updated_at)
528
608
  work_dir = _shorten_path(str(session.work_dir))
529
- total_messages = len([item for item in history if not isinstance(item, ToolResultItem)])
609
+ total_messages = len([item for item in history if not isinstance(item, model.ToolResultItem)])
530
610
  footer_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
531
611
  first_user_message = get_first_user_message(history)
532
612
 
@@ -8,18 +8,17 @@ from typing import ClassVar
8
8
  from pydantic import BaseModel, Field
9
9
 
10
10
  from klaude_code.protocol import events, model
11
- from klaude_code.protocol.model import ConversationItem, SubAgentState, TodoItem
12
11
 
13
12
 
14
13
  class Session(BaseModel):
15
14
  id: str = Field(default_factory=lambda: uuid.uuid4().hex)
16
15
  work_dir: Path
17
- conversation_history: list[ConversationItem] = Field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
18
- sub_agent_state: SubAgentState | None = None
16
+ conversation_history: list[model.ConversationItem] = Field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
17
+ sub_agent_state: model.SubAgentState | None = None
19
18
  # FileTracker: track file path -> last modification time when last read/edited
20
19
  file_tracker: dict[str, float] = Field(default_factory=dict)
21
20
  # Todo list for the session
22
- todos: list[TodoItem] = Field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
21
+ todos: list[model.TodoItem] = Field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
23
22
  # Messages count, redundant state for performance optimization to avoid reading entire jsonl file
24
23
  messages_count: int = Field(default=0)
25
24
  # Model name used for this session
@@ -88,7 +87,11 @@ class Session(BaseModel):
88
87
  def load(cls, id: str) -> "Session":
89
88
  # Load session metadata
90
89
  sessions_dir = cls._sessions_dir()
91
- session_candidates = sorted(sessions_dir.glob(f"*-{id}.json"), key=lambda p: p.stat().st_mtime, reverse=True)
90
+ session_candidates = sorted(
91
+ sessions_dir.glob(f"*-{id}.json"),
92
+ key=lambda p: p.stat().st_mtime,
93
+ reverse=True,
94
+ )
92
95
  if not session_candidates:
93
96
  # No existing session; create a new one
94
97
  return Session(id=id, work_dir=Path.cwd())
@@ -100,9 +103,9 @@ class Session(BaseModel):
100
103
  work_dir_str = raw.get("work_dir", str(Path.cwd()))
101
104
 
102
105
  sub_agent_state_raw = raw.get("sub_agent_state")
103
- sub_agent_state = SubAgentState(**sub_agent_state_raw) if sub_agent_state_raw else None
106
+ sub_agent_state = model.SubAgentState(**sub_agent_state_raw) if sub_agent_state_raw else None
104
107
  file_tracker = dict(raw.get("file_tracker", {}))
105
- todos: list[TodoItem] = [TodoItem(**item) for item in raw.get("todos", [])]
108
+ todos: list[model.TodoItem] = [model.TodoItem(**item) for item in raw.get("todos", [])]
106
109
  loaded_memory = list(raw.get("loaded_memory", []))
107
110
  created_at = float(raw.get("created_at", time.time()))
108
111
  updated_at = float(raw.get("updated_at", created_at))
@@ -125,10 +128,14 @@ class Session(BaseModel):
125
128
  # Load conversation history from messages JSONL
126
129
  messages_dir = cls._messages_dir()
127
130
  # Expect a single messages file per session (prefixed filenames only)
128
- msg_candidates = sorted(messages_dir.glob(f"*-{id}.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True)
131
+ msg_candidates = sorted(
132
+ messages_dir.glob(f"*-{id}.jsonl"),
133
+ key=lambda p: p.stat().st_mtime,
134
+ reverse=True,
135
+ )
129
136
  if msg_candidates:
130
137
  messages_path = msg_candidates[0]
131
- history: list[ConversationItem] = []
138
+ history: list[model.ConversationItem] = []
132
139
  for line in messages_path.read_text().splitlines():
133
140
  line = line.strip()
134
141
  if not line:
@@ -180,7 +187,7 @@ class Session(BaseModel):
180
187
  }
181
188
  self._session_file().write_text(json.dumps(payload, ensure_ascii=False, indent=2))
182
189
 
183
- def append_history(self, items: Sequence[ConversationItem]):
190
+ def append_history(self, items: Sequence[model.ConversationItem]):
184
191
  # Append to in-memory history
185
192
  self.conversation_history.extend(items)
186
193
  # Update messages count (only UserMessageItem and AssistantMessageItem)
@@ -197,7 +204,7 @@ class Session(BaseModel):
197
204
  for it in items:
198
205
  # Serialize with explicit type tag for reliable load
199
206
  t = it.__class__.__name__
200
- data = it.model_dump()
207
+ data = it.model_dump(mode="json")
201
208
  f.write(json.dumps({"type": t, "data": data}, ensure_ascii=False))
202
209
  f.write("\n")
203
210
  # Refresh metadata timestamp after history change
@@ -236,7 +243,10 @@ class Session(BaseModel):
236
243
  return False
237
244
  if prev_item is None:
238
245
  return True
239
- if isinstance(prev_item, model.UserMessageItem | model.ToolResultItem | model.DeveloperMessageItem):
246
+ if isinstance(
247
+ prev_item,
248
+ model.UserMessageItem | model.ToolResultItem | model.DeveloperMessageItem,
249
+ ):
240
250
  return True
241
251
  return False
242
252
 
@@ -337,7 +347,9 @@ class Session(BaseModel):
337
347
  if not msg_file.exists():
338
348
  # Try to find by pattern if exact file doesn't exist
339
349
  msg_candidates = sorted(
340
- messages_dir.glob(f"*-{session_id}.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True
350
+ messages_dir.glob(f"*-{session_id}.jsonl"),
351
+ key=lambda p: p.stat().st_mtime,
352
+ reverse=True,
341
353
  )
342
354
  if not msg_candidates:
343
355
  return None
@@ -358,7 +370,9 @@ class Session(BaseModel):
358
370
  # Handle structured content - extract text
359
371
  text_parts: list[str] = []
360
372
  for part in content: # pyright: ignore[reportUnknownVariableType]
361
- if isinstance(part, dict) and part.get("type") == "text": # pyright: ignore[reportUnknownMemberType]
373
+ if (
374
+ isinstance(part, dict) and part.get("type") == "text" # pyright: ignore[reportUnknownMemberType]
375
+ ):
362
376
  text = part.get("text", "") # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
363
377
  if isinstance(text, str):
364
378
  text_parts.append(text)