klaude-code 1.8.0__py3-none-any.whl → 2.0.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 (142) hide show
  1. klaude_code/auth/base.py +97 -0
  2. klaude_code/auth/claude/__init__.py +6 -0
  3. klaude_code/auth/claude/exceptions.py +9 -0
  4. klaude_code/auth/claude/oauth.py +172 -0
  5. klaude_code/auth/claude/token_manager.py +26 -0
  6. klaude_code/auth/codex/token_manager.py +10 -50
  7. klaude_code/cli/auth_cmd.py +127 -46
  8. klaude_code/cli/config_cmd.py +4 -2
  9. klaude_code/cli/cost_cmd.py +14 -9
  10. klaude_code/cli/list_model.py +248 -200
  11. klaude_code/cli/main.py +1 -1
  12. klaude_code/cli/runtime.py +7 -5
  13. klaude_code/cli/self_update.py +1 -1
  14. klaude_code/cli/session_cmd.py +1 -1
  15. klaude_code/command/clear_cmd.py +6 -2
  16. klaude_code/command/command_abc.py +2 -2
  17. klaude_code/command/debug_cmd.py +4 -4
  18. klaude_code/command/export_cmd.py +2 -2
  19. klaude_code/command/export_online_cmd.py +12 -12
  20. klaude_code/command/fork_session_cmd.py +29 -23
  21. klaude_code/command/help_cmd.py +4 -4
  22. klaude_code/command/model_cmd.py +4 -4
  23. klaude_code/command/model_select.py +1 -1
  24. klaude_code/command/prompt-commit.md +82 -0
  25. klaude_code/command/prompt_command.py +3 -3
  26. klaude_code/command/refresh_cmd.py +2 -2
  27. klaude_code/command/registry.py +7 -5
  28. klaude_code/command/release_notes_cmd.py +4 -4
  29. klaude_code/command/resume_cmd.py +15 -11
  30. klaude_code/command/status_cmd.py +4 -4
  31. klaude_code/command/terminal_setup_cmd.py +8 -8
  32. klaude_code/command/thinking_cmd.py +4 -4
  33. klaude_code/config/assets/builtin_config.yaml +52 -3
  34. klaude_code/config/builtin_config.py +16 -5
  35. klaude_code/config/config.py +31 -7
  36. klaude_code/config/thinking.py +4 -4
  37. klaude_code/const.py +146 -91
  38. klaude_code/core/agent.py +3 -12
  39. klaude_code/core/executor.py +21 -13
  40. klaude_code/core/manager/sub_agent_manager.py +71 -7
  41. klaude_code/core/prompt.py +1 -1
  42. klaude_code/core/prompts/prompt-sub-agent-image-gen.md +1 -0
  43. klaude_code/core/prompts/prompt-sub-agent-web.md +27 -1
  44. klaude_code/core/reminders.py +88 -69
  45. klaude_code/core/task.py +44 -45
  46. klaude_code/core/tool/file/apply_patch_tool.py +9 -9
  47. klaude_code/core/tool/file/diff_builder.py +3 -5
  48. klaude_code/core/tool/file/edit_tool.py +23 -23
  49. klaude_code/core/tool/file/move_tool.py +43 -43
  50. klaude_code/core/tool/file/read_tool.py +44 -39
  51. klaude_code/core/tool/file/write_tool.py +14 -14
  52. klaude_code/core/tool/report_back_tool.py +4 -4
  53. klaude_code/core/tool/shell/bash_tool.py +23 -23
  54. klaude_code/core/tool/skill/skill_tool.py +7 -7
  55. klaude_code/core/tool/sub_agent_tool.py +38 -9
  56. klaude_code/core/tool/todo/todo_write_tool.py +8 -8
  57. klaude_code/core/tool/todo/update_plan_tool.py +6 -6
  58. klaude_code/core/tool/tool_abc.py +2 -2
  59. klaude_code/core/tool/tool_context.py +27 -0
  60. klaude_code/core/tool/tool_runner.py +88 -42
  61. klaude_code/core/tool/truncation.py +38 -20
  62. klaude_code/core/tool/web/mermaid_tool.py +6 -7
  63. klaude_code/core/tool/web/web_fetch_tool.py +68 -30
  64. klaude_code/core/tool/web/web_search_tool.py +15 -17
  65. klaude_code/core/turn.py +120 -73
  66. klaude_code/llm/anthropic/client.py +104 -44
  67. klaude_code/llm/anthropic/input.py +116 -108
  68. klaude_code/llm/bedrock/client.py +8 -5
  69. klaude_code/llm/claude/__init__.py +3 -0
  70. klaude_code/llm/claude/client.py +105 -0
  71. klaude_code/llm/client.py +4 -3
  72. klaude_code/llm/codex/client.py +16 -10
  73. klaude_code/llm/google/client.py +122 -60
  74. klaude_code/llm/google/input.py +94 -108
  75. klaude_code/llm/image.py +123 -0
  76. klaude_code/llm/input_common.py +136 -189
  77. klaude_code/llm/openai_compatible/client.py +17 -7
  78. klaude_code/llm/openai_compatible/input.py +36 -66
  79. klaude_code/llm/openai_compatible/stream.py +119 -67
  80. klaude_code/llm/openai_compatible/tool_call_accumulator.py +23 -11
  81. klaude_code/llm/openrouter/client.py +34 -9
  82. klaude_code/llm/openrouter/input.py +63 -64
  83. klaude_code/llm/openrouter/reasoning.py +22 -24
  84. klaude_code/llm/registry.py +20 -15
  85. klaude_code/llm/responses/client.py +107 -45
  86. klaude_code/llm/responses/input.py +115 -98
  87. klaude_code/llm/usage.py +52 -25
  88. klaude_code/protocol/__init__.py +1 -0
  89. klaude_code/protocol/events.py +16 -12
  90. klaude_code/protocol/llm_param.py +22 -3
  91. klaude_code/protocol/message.py +250 -0
  92. klaude_code/protocol/model.py +94 -281
  93. klaude_code/protocol/op.py +2 -2
  94. klaude_code/protocol/sub_agent/__init__.py +2 -2
  95. klaude_code/protocol/sub_agent/explore.py +10 -0
  96. klaude_code/protocol/sub_agent/image_gen.py +119 -0
  97. klaude_code/protocol/sub_agent/task.py +10 -0
  98. klaude_code/protocol/sub_agent/web.py +10 -0
  99. klaude_code/session/codec.py +6 -6
  100. klaude_code/session/export.py +261 -62
  101. klaude_code/session/selector.py +7 -24
  102. klaude_code/session/session.py +125 -53
  103. klaude_code/session/store.py +5 -32
  104. klaude_code/session/templates/export_session.html +1 -1
  105. klaude_code/session/templates/mermaid_viewer.html +1 -1
  106. klaude_code/trace/log.py +11 -6
  107. klaude_code/ui/core/input.py +1 -1
  108. klaude_code/ui/core/stage_manager.py +1 -8
  109. klaude_code/ui/modes/debug/display.py +2 -2
  110. klaude_code/ui/modes/repl/clipboard.py +2 -2
  111. klaude_code/ui/modes/repl/completers.py +18 -10
  112. klaude_code/ui/modes/repl/event_handler.py +136 -127
  113. klaude_code/ui/modes/repl/input_prompt_toolkit.py +1 -1
  114. klaude_code/ui/modes/repl/key_bindings.py +1 -1
  115. klaude_code/ui/modes/repl/renderer.py +107 -15
  116. klaude_code/ui/renderers/assistant.py +2 -2
  117. klaude_code/ui/renderers/common.py +65 -7
  118. klaude_code/ui/renderers/developer.py +7 -6
  119. klaude_code/ui/renderers/diffs.py +11 -11
  120. klaude_code/ui/renderers/mermaid_viewer.py +49 -2
  121. klaude_code/ui/renderers/metadata.py +39 -31
  122. klaude_code/ui/renderers/sub_agent.py +57 -16
  123. klaude_code/ui/renderers/thinking.py +37 -2
  124. klaude_code/ui/renderers/tools.py +180 -165
  125. klaude_code/ui/rich/live.py +3 -1
  126. klaude_code/ui/rich/markdown.py +39 -7
  127. klaude_code/ui/rich/quote.py +76 -1
  128. klaude_code/ui/rich/status.py +14 -8
  129. klaude_code/ui/rich/theme.py +13 -6
  130. klaude_code/ui/terminal/image.py +34 -0
  131. klaude_code/ui/terminal/notifier.py +2 -1
  132. klaude_code/ui/terminal/progress_bar.py +4 -4
  133. klaude_code/ui/terminal/selector.py +22 -4
  134. klaude_code/ui/utils/common.py +55 -0
  135. {klaude_code-1.8.0.dist-info → klaude_code-2.0.0.dist-info}/METADATA +28 -6
  136. klaude_code-2.0.0.dist-info/RECORD +229 -0
  137. klaude_code/command/prompt-jj-describe.md +0 -32
  138. klaude_code/core/prompts/prompt-sub-agent-oracle.md +0 -22
  139. klaude_code/protocol/sub_agent/oracle.py +0 -91
  140. klaude_code-1.8.0.dist-info/RECORD +0 -219
  141. {klaude_code-1.8.0.dist-info → klaude_code-2.0.0.dist-info}/WHEEL +0 -0
  142. {klaude_code-1.8.0.dist-info → klaude_code-2.0.0.dist-info}/entry_points.txt +0 -0
@@ -2,16 +2,18 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import base64
5
6
  import html
6
7
  import importlib.resources
7
8
  import json
9
+ import mimetypes
8
10
  import re
9
11
  from datetime import datetime
10
12
  from pathlib import Path
11
13
  from string import Template
12
14
  from typing import TYPE_CHECKING, Any, Final, cast
13
15
 
14
- from klaude_code.protocol import llm_param, model
16
+ from klaude_code.protocol import llm_param, message, model
15
17
  from klaude_code.protocol.sub_agent import is_sub_agent_tool
16
18
 
17
19
  if TYPE_CHECKING:
@@ -19,6 +21,59 @@ if TYPE_CHECKING:
19
21
 
20
22
  _TOOL_OUTPUT_PREVIEW_LINES: Final[int] = 12
21
23
  _MAX_FILENAME_MESSAGE_LEN: Final[int] = 50
24
+ _IMAGE_MAX_DISPLAY_WIDTH: Final[int] = 600
25
+
26
+
27
+ def _image_to_data_url(file_path: str) -> str | None:
28
+ """Read an image file and convert it to a base64 data URL.
29
+
30
+ Returns None if the file doesn't exist or can't be read.
31
+ """
32
+ path = Path(file_path)
33
+ if not path.exists():
34
+ return None
35
+
36
+ mime_type, _ = mimetypes.guess_type(str(path))
37
+ if not mime_type or not mime_type.startswith("image/"):
38
+ mime_type = "image/png"
39
+
40
+ try:
41
+ data = path.read_bytes()
42
+ b64 = base64.b64encode(data).decode("ascii")
43
+ return f"data:{mime_type};base64,{b64}"
44
+ except OSError:
45
+ return None
46
+
47
+
48
+ def _render_image_html(file_path: str, max_width: int = _IMAGE_MAX_DISPLAY_WIDTH) -> str:
49
+ """Render an image as HTML img tag with base64 data URL."""
50
+ data_url = _image_to_data_url(file_path)
51
+ if data_url:
52
+ short_path = _shorten_path(file_path)
53
+ return (
54
+ f'<div class="assistant-image" style="margin: 8px 0;">'
55
+ f'<img src="{data_url}" alt="Generated image" '
56
+ f'style="max-width: {max_width}px; border-radius: 4px; border: 1px solid var(--border);" />'
57
+ f'<div style="font-size: 12px; color: var(--text-dim); margin-top: 4px;">{_escape_html(short_path)}</div>'
58
+ f"</div>"
59
+ )
60
+ short_path = _shorten_path(file_path)
61
+ return f'<div class="assistant-image-missing" style="color: var(--text-dim); font-style: italic;">Image not found: {_escape_html(short_path)}</div>'
62
+
63
+
64
+ def _render_image_url_html(url: str, max_width: int = _IMAGE_MAX_DISPLAY_WIDTH) -> str:
65
+ """Render an image URL as HTML img tag."""
66
+ short_url = _escape_html(_shorten_path(url))
67
+ caption = ""
68
+ if not url.startswith("data:"):
69
+ caption = f'<div style="font-size: 12px; color: var(--text-dim); margin-top: 4px;">{short_url}</div>'
70
+ return (
71
+ f'<div class="assistant-image" style="margin: 8px 0;">'
72
+ f'<img src="{_escape_html(url)}" alt="Image" '
73
+ f'style="max-width: {max_width}px; border-radius: 4px; border: 1px solid var(--border);" />'
74
+ f"{caption}"
75
+ f"</div>"
76
+ )
22
77
 
23
78
 
24
79
  def _sanitize_filename(text: str) -> str:
@@ -49,11 +104,13 @@ def _format_msg_timestamp(dt: datetime) -> str:
49
104
  return dt.strftime("%Y-%m-%d %H:%M:%S")
50
105
 
51
106
 
52
- def get_first_user_message(history: list[model.ConversationItem]) -> str:
107
+ def get_first_user_message(history: list[message.HistoryEvent]) -> str:
53
108
  """Extract the first user message content from conversation history."""
54
109
  for item in history:
55
- if isinstance(item, model.UserMessageItem) and item.content:
56
- content = item.content.strip()
110
+ if isinstance(item, message.UserMessage):
111
+ content = message.join_text_parts(item.parts).strip()
112
+ if not content:
113
+ continue
57
114
  first_line = content.split("\n")[0]
58
115
  return first_line[:100] if len(first_line) > 100 else first_line
59
116
  return "export"
@@ -244,9 +301,25 @@ def _render_metadata_item(item: model.TaskMetadataItem) -> str:
244
301
  return f'<div class="response-metadata">{"".join(lines)}</div>'
245
302
 
246
303
 
247
- def _render_assistant_message(index: int, content: str, timestamp: datetime) -> str:
304
+ def _render_assistant_message(
305
+ index: int,
306
+ content: str,
307
+ timestamp: datetime,
308
+ images: list[message.ImageFilePart | message.ImageURLPart] | None = None,
309
+ ) -> str:
248
310
  encoded = _escape_html(content)
249
311
  ts_str = _format_msg_timestamp(timestamp)
312
+
313
+ images_html = ""
314
+ if images:
315
+ images_parts: list[str] = []
316
+ for img in images:
317
+ if isinstance(img, message.ImageFilePart):
318
+ images_parts.append(_render_image_html(img.file_path))
319
+ else:
320
+ images_parts.append(_render_image_url_html(img.url))
321
+ images_html = "".join(images_parts)
322
+
250
323
  return (
251
324
  f'<div class="message-group assistant-message-group">'
252
325
  f'<div class="message-header">'
@@ -258,6 +331,7 @@ def _render_assistant_message(index: int, content: str, timestamp: datetime) ->
258
331
  f"</div>"
259
332
  f"</div>"
260
333
  f'<div class="message-content assistant-message">'
334
+ f"{images_html}"
261
335
  f'<div class="assistant-rendered markdown-content markdown-body" data-raw="{encoded}">'
262
336
  f'<noscript><pre style="white-space: pre-wrap;">{encoded}</pre></noscript>'
263
337
  f"</div>"
@@ -267,6 +341,29 @@ def _render_assistant_message(index: int, content: str, timestamp: datetime) ->
267
341
  )
268
342
 
269
343
 
344
+ def _extract_image_parts(parts: list[message.Part]) -> list[message.ImageFilePart | message.ImageURLPart]:
345
+ images: list[message.ImageFilePart | message.ImageURLPart] = []
346
+ for part in parts:
347
+ if isinstance(part, (message.ImageFilePart, message.ImageURLPart)):
348
+ images.append(part)
349
+ return images
350
+
351
+
352
+ def _render_image_parts(images: list[message.ImageFilePart | message.ImageURLPart]) -> str:
353
+ rendered: list[str] = []
354
+ for img in images:
355
+ if isinstance(img, message.ImageFilePart):
356
+ rendered.append(_render_image_html(img.file_path))
357
+ else:
358
+ rendered.append(_render_image_url_html(img.url))
359
+ return "".join(rendered)
360
+
361
+
362
+ def _render_thinking_block(text: str) -> str:
363
+ encoded = _escape_html(text.strip())
364
+ return f'<div class="thinking-block markdown-body markdown-content" data-raw="{encoded}"></div>'
365
+
366
+
270
367
  def _try_render_todo_args(arguments: str, tool_name: str) -> str | None:
271
368
  try:
272
369
  parsed = json.loads(arguments)
@@ -308,20 +405,64 @@ def _try_render_todo_args(arguments: str, tool_name: str) -> str | None:
308
405
  return None
309
406
 
310
407
 
408
+ def _extract_saved_images(content: str) -> tuple[str, list[str]]:
409
+ """Extract image paths from 'Saved images:' section in content.
410
+
411
+ Returns:
412
+ Tuple of (remaining_text, list_of_image_paths).
413
+ """
414
+ image_paths: list[str] = []
415
+ lines = content.splitlines()
416
+ result_lines: list[str] = []
417
+ in_saved_images = False
418
+
419
+ for line in lines:
420
+ stripped = line.strip()
421
+ if stripped == "Saved images:":
422
+ in_saved_images = True
423
+ continue
424
+ if in_saved_images:
425
+ if stripped.startswith("- "):
426
+ path = stripped[2:].strip()
427
+ if path:
428
+ image_paths.append(path)
429
+ continue
430
+ # End of saved images section (non-list line)
431
+ in_saved_images = False
432
+ result_lines.append(line)
433
+
434
+ return "\n".join(result_lines).strip(), image_paths
435
+
436
+
311
437
  def _render_sub_agent_result(content: str, description: str | None = None) -> str:
312
- # Try to format as JSON for better readability
438
+ # Extract saved images from content
439
+ text_content, image_paths = _extract_saved_images(content)
440
+
441
+ # Render images first
442
+ images_html = ""
443
+ if image_paths:
444
+ images_parts = [_render_image_html(path) for path in image_paths]
445
+ images_html = "".join(images_parts)
446
+
447
+ # Try to format remaining text as JSON for better readability
313
448
  try:
314
- parsed = json.loads(content)
449
+ parsed = json.loads(text_content)
315
450
  formatted = "```json\n" + json.dumps(parsed, ensure_ascii=False, indent=2) + "\n```"
316
451
  except (json.JSONDecodeError, TypeError):
317
- formatted = content
452
+ formatted = text_content
318
453
 
319
454
  if description:
320
455
  formatted = f"# {description}\n\n{formatted}"
321
456
 
322
457
  encoded = _escape_html(formatted)
458
+
459
+ # If we have images but no text, just show images
460
+ if images_html and not formatted.strip():
461
+ return f'<div class="sub-agent-result-container">{images_html}</div>'
462
+
323
463
  return (
324
464
  f'<div class="sub-agent-result-container">'
465
+ f"{images_html}"
325
466
  f'<div class="sub-agent-toolbar">'
326
467
  f'<button type="button" class="raw-toggle" aria-pressed="false" title="Toggle raw text view">Raw</button>'
327
468
  f'<button type="button" class="copy-raw-btn" title="Copy raw content">Copy</button>'
@@ -477,7 +618,7 @@ def _build_add_only_diff(text: str, file_path: str) -> model.DiffUIExtra:
477
618
 
478
619
 
479
620
  def _get_mermaid_link_html(
480
- ui_extra: model.ToolResultUIExtra | None, tool_call: model.ToolCallItem | None = None
621
+ ui_extra: model.ToolResultUIExtra | None, tool_call: message.ToolCallPart | None = None
481
622
  ) -> str | None:
482
623
  code = ""
483
624
  link: str | None = None
@@ -488,9 +629,9 @@ def _get_mermaid_link_html(
488
629
  link = ui_extra.link
489
630
  line_count = ui_extra.line_count
490
631
 
491
- if not code and tool_call and tool_call.name == "Mermaid":
632
+ if not code and tool_call and tool_call.tool_name == "Mermaid":
492
633
  try:
493
- args = json.loads(tool_call.arguments)
634
+ args = json.loads(tool_call.arguments_json)
494
635
  code = args.get("code", "")
495
636
  except (json.JSONDecodeError, TypeError):
496
637
  code = ""
@@ -544,22 +685,26 @@ def _get_mermaid_link_html(
544
685
  return toolbar_html
545
686
 
546
687
 
547
- def _format_tool_call(tool_call: model.ToolCallItem, result: model.ToolResultItem | None) -> str:
688
+ def _format_tool_call(
689
+ tool_call: message.ToolCallPart,
690
+ result: message.ToolResultMessage | None,
691
+ timestamp: datetime,
692
+ ) -> str:
548
693
  args_html = None
549
694
  is_todo_list = False
550
- ts_str = _format_msg_timestamp(tool_call.created_at)
695
+ ts_str = _format_msg_timestamp(timestamp)
551
696
 
552
- if tool_call.name in ("TodoWrite", "update_plan"):
553
- args_html = _try_render_todo_args(tool_call.arguments, tool_call.name)
697
+ if tool_call.tool_name in ("TodoWrite", "update_plan"):
698
+ args_html = _try_render_todo_args(tool_call.arguments_json, tool_call.tool_name)
554
699
  if args_html:
555
700
  is_todo_list = True
556
701
 
557
702
  if args_html is None:
558
703
  try:
559
- parsed = json.loads(tool_call.arguments)
704
+ parsed = json.loads(tool_call.arguments_json)
560
705
  args_text = json.dumps(parsed, ensure_ascii=False, indent=2)
561
706
  except (json.JSONDecodeError, TypeError):
562
- args_text = tool_call.arguments
707
+ args_text = tool_call.arguments_json
563
708
 
564
709
  args_html = _escape_html(args_text or "")
565
710
 
@@ -572,12 +717,12 @@ def _format_tool_call(tool_call: model.ToolCallItem, result: model.ToolResultIte
572
717
  else:
573
718
  # Always collapse Mermaid, Edit, Write tools by default
574
719
  always_collapse_tools = {"Mermaid", "Edit", "Write"}
575
- force_collapse = tool_call.name in always_collapse_tools
720
+ force_collapse = tool_call.tool_name in always_collapse_tools
576
721
 
577
722
  # Collapse Memory tool for write operations
578
- if tool_call.name == "Memory":
723
+ if tool_call.tool_name == "Memory":
579
724
  try:
580
- parsed_args = json.loads(tool_call.arguments)
725
+ parsed_args = json.loads(tool_call.arguments_json)
581
726
  if parsed_args.get("command") in {"create", "str_replace", "insert"}:
582
727
  force_collapse = True
583
728
  except (json.JSONDecodeError, TypeError):
@@ -595,7 +740,7 @@ def _format_tool_call(tool_call: model.ToolCallItem, result: model.ToolResultIte
595
740
  html_parts = [
596
741
  '<div class="tool-call">',
597
742
  '<div class="tool-header">',
598
- f'<span class="tool-name">{_escape_html(tool_call.name)}</span>',
743
+ f'<span class="tool-name">{_escape_html(tool_call.tool_name)}</span>',
599
744
  '<div class="tool-header-right">',
600
745
  f'<span class="tool-id">{_escape_html(tool_call.call_id)}</span>',
601
746
  f'<span class="timestamp">{ts_str}</span>',
@@ -611,15 +756,15 @@ def _format_tool_call(tool_call: model.ToolCallItem, result: model.ToolResultIte
611
756
  mermaid_source = mermaid_extra if mermaid_extra else result.ui_extra
612
757
  mermaid_html = _get_mermaid_link_html(mermaid_source, tool_call)
613
758
 
614
- should_hide_text = tool_call.name in ("TodoWrite", "update_plan") and result.status != "error"
759
+ should_hide_text = tool_call.tool_name in ("TodoWrite", "update_plan") and result.status != "error"
615
760
 
616
761
  if (
617
- tool_call.name == "Edit"
762
+ tool_call.tool_name == "Edit"
618
763
  and not any(isinstance(x, model.DiffUIExtra) for x in extras)
619
764
  and result.status != "error"
620
765
  ):
621
766
  try:
622
- args_data = json.loads(tool_call.arguments)
767
+ args_data = json.loads(tool_call.arguments_json)
623
768
  file_path = args_data.get("file_path", "Unknown file")
624
769
  old_string = args_data.get("old_string", "")
625
770
  new_string = args_data.get("new_string", "")
@@ -630,19 +775,26 @@ def _format_tool_call(tool_call: model.ToolCallItem, result: model.ToolResultIte
630
775
 
631
776
  items_to_render: list[str] = []
632
777
 
633
- if result.output and not should_hide_text:
634
- if is_sub_agent_tool(tool_call.name):
778
+ image_parts = _extract_image_parts(result.parts)
779
+ for img in image_parts:
780
+ if isinstance(img, message.ImageFilePart):
781
+ items_to_render.append(_render_image_html(img.file_path))
782
+ else:
783
+ items_to_render.append(_render_image_url_html(img.url))
784
+
785
+ if result.output_text and not should_hide_text:
786
+ if is_sub_agent_tool(tool_call.tool_name):
635
787
  description = None
636
788
  try:
637
- args = json.loads(tool_call.arguments)
789
+ args = json.loads(tool_call.arguments_json)
638
790
  if isinstance(args, dict):
639
791
  typed_args = cast(dict[str, Any], args)
640
792
  description = cast(str | None, typed_args.get("description"))
641
793
  except (json.JSONDecodeError, TypeError):
642
794
  pass
643
- items_to_render.append(_render_sub_agent_result(result.output, description))
795
+ items_to_render.append(_render_sub_agent_result(result.output_text, description))
644
796
  else:
645
- items_to_render.append(_render_text_block(result.output))
797
+ items_to_render.append(_render_text_block(result.output_text))
646
798
 
647
799
  for extra in extras:
648
800
  if isinstance(extra, model.DiffUIExtra):
@@ -653,11 +805,11 @@ def _format_tool_call(tool_call: model.ToolCallItem, result: model.ToolResultIte
653
805
  if mermaid_html:
654
806
  items_to_render.append(mermaid_html)
655
807
 
656
- if not items_to_render and not result.output and not should_hide_text:
808
+ if not items_to_render and not result.output_text and not should_hide_text:
657
809
  items_to_render.append('<div style="color: var(--text-dim); font-style: italic;">(empty output)</div>')
658
810
 
659
811
  if items_to_render:
660
- status_class = result.status if result.status in ("success", "error") else "success"
812
+ status_class = "success" if result.status == "success" else "error"
661
813
  html_parts.append(f'<div class="tool-result {status_class}">')
662
814
  html_parts.extend(items_to_render)
663
815
  html_parts.append("</div>")
@@ -669,8 +821,8 @@ def _format_tool_call(tool_call: model.ToolCallItem, result: model.ToolResultIte
669
821
 
670
822
 
671
823
  def _build_messages_html(
672
- history: list[model.ConversationItem],
673
- tool_results: dict[str, model.ToolResultItem],
824
+ history: list[message.HistoryEvent],
825
+ tool_results: dict[str, message.ToolResultMessage],
674
826
  *,
675
827
  seen_session_ids: set[str] | None = None,
676
828
  nesting_level: int = 0,
@@ -681,65 +833,106 @@ def _build_messages_html(
681
833
  blocks: list[str] = []
682
834
  assistant_counter = 0
683
835
 
684
- renderable_items = [
685
- item for item in history if not isinstance(item, (model.ToolResultItem, model.ReasoningEncryptedItem))
686
- ]
836
+ renderable_items = [item for item in history if not isinstance(item, message.ToolResultMessage)]
687
837
 
688
838
  for i, item in enumerate(renderable_items):
689
- if isinstance(item, model.UserMessageItem):
690
- text = _escape_html(item.content or "")
839
+ if isinstance(item, message.UserMessage):
840
+ text = message.join_text_parts(item.parts)
841
+ images = _extract_image_parts(item.parts)
842
+ images_html = _render_image_parts(images)
691
843
  ts_str = _format_msg_timestamp(item.created_at)
844
+ body_parts: list[str] = []
845
+ if images_html:
846
+ body_parts.append(images_html)
847
+ if text:
848
+ body_parts.append(f'<div style="white-space: pre-wrap;">{_escape_html(text)}</div>')
849
+ if not body_parts:
850
+ body_parts.append('<div style="color: var(--text-dim); font-style: italic;">(empty)</div>')
692
851
  blocks.append(
693
852
  f'<div class="message-group">'
694
853
  f'<div class="role-label user">'
695
854
  f"User"
696
855
  f'<span class="timestamp">{ts_str}</span>'
697
856
  f"</div>"
698
- f'<div class="message-content user" style="white-space: pre-wrap;">{text}</div>'
857
+ f'<div class="message-content user">{"".join(body_parts)}</div>'
699
858
  f"</div>"
700
859
  )
701
- elif isinstance(item, model.ReasoningTextItem):
702
- text = _escape_html(item.content.strip())
703
- blocks.append(f'<div class="thinking-block markdown-body markdown-content" data-raw="{text}"></div>')
704
- elif isinstance(item, model.AssistantMessageItem):
860
+ elif isinstance(item, message.AssistantMessage):
705
861
  assistant_counter += 1
706
- blocks.append(_render_assistant_message(assistant_counter, item.content or "", item.created_at))
862
+ thinking_text = "".join(part.text for part in item.parts if isinstance(part, message.ThinkingTextPart))
863
+ if thinking_text:
864
+ blocks.append(_render_thinking_block(thinking_text))
865
+
866
+ assistant_text = message.join_text_parts(item.parts)
867
+ assistant_images = _extract_image_parts(item.parts)
868
+ if assistant_text or assistant_images:
869
+ blocks.append(
870
+ _render_assistant_message(
871
+ assistant_counter,
872
+ assistant_text,
873
+ item.created_at,
874
+ assistant_images,
875
+ )
876
+ )
877
+
878
+ for part in item.parts:
879
+ if isinstance(part, message.ToolCallPart):
880
+ result = tool_results.get(part.call_id)
881
+ blocks.append(_format_tool_call(part, result, item.created_at))
882
+ if result is not None:
883
+ sub_agent_html = _render_sub_agent_session(result, seen_session_ids, nesting_level)
884
+ if sub_agent_html:
885
+ blocks.append(sub_agent_html)
707
886
  elif isinstance(item, model.TaskMetadataItem):
708
887
  blocks.append(_render_metadata_item(item))
709
- elif isinstance(item, model.DeveloperMessageItem):
710
- content = _escape_html(item.content or "")
888
+ elif isinstance(item, message.DeveloperMessage):
889
+ content = message.join_text_parts(item.parts)
890
+ images = _extract_image_parts(item.parts)
891
+ images_html = _render_image_parts(images)
711
892
  ts_str = _format_msg_timestamp(item.created_at)
712
893
 
713
894
  next_item = renderable_items[i + 1] if i + 1 < len(renderable_items) else None
714
895
  extra_class = ""
715
- if isinstance(next_item, (model.UserMessageItem, model.AssistantMessageItem)):
896
+ if isinstance(next_item, (message.UserMessage, message.AssistantMessage)):
716
897
  extra_class = " gap-below"
717
898
 
899
+ detail_body = ""
900
+ if images_html:
901
+ detail_body += images_html
902
+ if content:
903
+ detail_body += f'<div style="white-space: pre-wrap;">{_escape_html(content)}</div>'
904
+ if not detail_body:
905
+ detail_body = '<div style="color: var(--text-dim); font-style: italic;">(empty)</div>'
906
+
718
907
  blocks.append(
719
908
  f'<details class="developer-message{extra_class}">'
720
909
  f"<summary>"
721
910
  f"Developer"
722
911
  f'<span class="timestamp">{ts_str}</span>'
723
912
  f"</summary>"
724
- f'<div class="details-content" style="white-space: pre-wrap;">{content}</div>'
913
+ f'<div class="details-content">{detail_body}</div>'
914
+ f"</details>"
915
+ )
916
+ elif isinstance(item, message.SystemMessage):
917
+ content = message.join_text_parts(item.parts)
918
+ if not content:
919
+ continue
920
+ ts_str = _format_msg_timestamp(item.created_at)
921
+ blocks.append(
922
+ f'<details class="developer-message">'
923
+ f"<summary>"
924
+ f"System"
925
+ f'<span class="timestamp">{ts_str}</span>'
926
+ f"</summary>"
927
+ f'<div class="details-content" style="white-space: pre-wrap;">{_escape_html(content)}</div>'
725
928
  f"</details>"
726
929
  )
727
-
728
- elif isinstance(item, model.ToolCallItem):
729
- result = tool_results.get(item.call_id)
730
- blocks.append(_format_tool_call(item, result))
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
930
 
738
931
  return "\n".join(blocks)
739
932
 
740
933
 
741
934
  def _render_sub_agent_session(
742
- tool_result: model.ToolResultItem,
935
+ tool_result: message.ToolResultMessage,
743
936
  seen_session_ids: set[str],
744
937
  nesting_level: int,
745
938
  ) -> str | None:
@@ -762,7 +955,7 @@ def _render_sub_agent_session(
762
955
  return None
763
956
 
764
957
  sub_history = sub_session.conversation_history
765
- sub_tool_results = {item.call_id: item for item in sub_history if isinstance(item, model.ToolResultItem)}
958
+ sub_tool_results = {item.call_id: item for item in sub_history if isinstance(item, message.ToolResultMessage)}
766
959
 
767
960
  sub_html = _build_messages_html(
768
961
  sub_history,
@@ -802,7 +995,7 @@ def build_export_html(
802
995
  Complete HTML document as a string.
803
996
  """
804
997
  history = session.conversation_history
805
- tool_results = {item.call_id: item for item in history if isinstance(item, model.ToolResultItem)}
998
+ tool_results = {item.call_id: item for item in history if isinstance(item, message.ToolResultMessage)}
806
999
  messages_html = _build_messages_html(history, tool_results)
807
1000
  if not messages_html:
808
1001
  messages_html = '<div class="text-dim p-4 italic">No messages recorded for this session yet.</div>'
@@ -811,7 +1004,13 @@ def build_export_html(
811
1004
  session_id = session.id
812
1005
  session_updated = _format_timestamp(session.updated_at)
813
1006
  work_dir = _shorten_path(str(session.work_dir))
814
- total_messages = len([item for item in history if not isinstance(item, model.ToolResultItem)])
1007
+ total_messages = len(
1008
+ [
1009
+ item
1010
+ for item in history
1011
+ if isinstance(item, message.Message) and not isinstance(item, message.ToolResultMessage)
1012
+ ]
1013
+ )
815
1014
  footer_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
816
1015
  first_user_message = get_first_user_message(history)
817
1016
 
@@ -1,31 +1,14 @@
1
- import time
2
1
  from dataclasses import dataclass
3
2
 
4
3
  from .session import Session
5
4
 
6
5
 
7
- def _relative_time(ts: float) -> str:
8
- """Format timestamp as relative time like '5 days ago'."""
9
- now = time.time()
10
- diff = now - ts
11
-
12
- if diff < 60:
13
- return "just now"
14
- elif diff < 3600:
15
- mins = int(diff / 60)
16
- return f"{mins} minute{'s' if mins != 1 else ''} ago"
17
- elif diff < 86400:
18
- hours = int(diff / 3600)
19
- return f"{hours} hour{'s' if hours != 1 else ''} ago"
20
- elif diff < 604800:
21
- days = int(diff / 86400)
22
- return f"{days} day{'s' if days != 1 else ''} ago"
23
- elif diff < 2592000:
24
- weeks = int(diff / 604800)
25
- return f"{weeks} week{'s' if weeks != 1 else ''} ago"
26
- else:
27
- months = int(diff / 2592000)
28
- return f"{months} month{'s' if months != 1 else ''} ago"
6
+ def _format_time(ts: float) -> str:
7
+ """Format timestamp as absolute time like '01-01 14:30'."""
8
+ from datetime import datetime
9
+
10
+ dt = datetime.fromtimestamp(ts)
11
+ return dt.strftime("%m-%d %H:%M")
29
12
 
30
13
 
31
14
  @dataclass(frozen=True, slots=True)
@@ -90,7 +73,7 @@ def build_session_select_options() -> list[SessionSelectOption]:
90
73
  session_id=str(s.id),
91
74
  user_messages=user_messages,
92
75
  messages_count=msg_count,
93
- relative_time=_relative_time(s.updated_at),
76
+ relative_time=_format_time(s.updated_at),
94
77
  model_name=model,
95
78
  )
96
79
  )