klaude-code 2.7.0__py3-none-any.whl → 2.8.1__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 (74) hide show
  1. klaude_code/auth/AGENTS.md +325 -0
  2. klaude_code/auth/__init__.py +17 -1
  3. klaude_code/auth/antigravity/__init__.py +20 -0
  4. klaude_code/auth/antigravity/exceptions.py +17 -0
  5. klaude_code/auth/antigravity/oauth.py +320 -0
  6. klaude_code/auth/antigravity/pkce.py +25 -0
  7. klaude_code/auth/antigravity/token_manager.py +45 -0
  8. klaude_code/auth/base.py +4 -0
  9. klaude_code/auth/claude/oauth.py +29 -9
  10. klaude_code/auth/codex/exceptions.py +4 -0
  11. klaude_code/cli/auth_cmd.py +53 -3
  12. klaude_code/cli/cost_cmd.py +83 -160
  13. klaude_code/cli/list_model.py +50 -0
  14. klaude_code/cli/main.py +2 -2
  15. klaude_code/config/assets/builtin_config.yaml +108 -0
  16. klaude_code/config/builtin_config.py +5 -11
  17. klaude_code/config/config.py +24 -10
  18. klaude_code/const.py +2 -1
  19. klaude_code/core/agent.py +5 -1
  20. klaude_code/core/agent_profile.py +29 -33
  21. klaude_code/core/compaction/AGENTS.md +112 -0
  22. klaude_code/core/compaction/__init__.py +11 -0
  23. klaude_code/core/compaction/compaction.py +705 -0
  24. klaude_code/core/compaction/overflow.py +30 -0
  25. klaude_code/core/compaction/prompts.py +97 -0
  26. klaude_code/core/executor.py +121 -2
  27. klaude_code/core/manager/llm_clients.py +5 -0
  28. klaude_code/core/manager/llm_clients_builder.py +14 -2
  29. klaude_code/core/prompts/prompt-antigravity.md +80 -0
  30. klaude_code/core/prompts/prompt-codex-gpt-5-2.md +335 -0
  31. klaude_code/core/reminders.py +7 -2
  32. klaude_code/core/task.py +126 -0
  33. klaude_code/core/tool/file/edit_tool.py +1 -2
  34. klaude_code/core/tool/todo/todo_write_tool.py +1 -1
  35. klaude_code/core/turn.py +3 -1
  36. klaude_code/llm/antigravity/__init__.py +3 -0
  37. klaude_code/llm/antigravity/client.py +558 -0
  38. klaude_code/llm/antigravity/input.py +261 -0
  39. klaude_code/llm/registry.py +1 -0
  40. klaude_code/protocol/commands.py +1 -0
  41. klaude_code/protocol/events.py +18 -0
  42. klaude_code/protocol/llm_param.py +1 -0
  43. klaude_code/protocol/message.py +23 -1
  44. klaude_code/protocol/op.py +29 -1
  45. klaude_code/protocol/op_handler.py +10 -0
  46. klaude_code/session/export.py +308 -299
  47. klaude_code/session/session.py +36 -0
  48. klaude_code/session/templates/export_session.html +430 -134
  49. klaude_code/skill/assets/create-plan/SKILL.md +6 -6
  50. klaude_code/tui/command/__init__.py +6 -0
  51. klaude_code/tui/command/compact_cmd.py +32 -0
  52. klaude_code/tui/command/continue_cmd.py +34 -0
  53. klaude_code/tui/command/fork_session_cmd.py +110 -14
  54. klaude_code/tui/command/model_picker.py +5 -1
  55. klaude_code/tui/command/thinking_cmd.py +1 -1
  56. klaude_code/tui/commands.py +6 -0
  57. klaude_code/tui/components/rich/markdown.py +119 -12
  58. klaude_code/tui/components/rich/theme.py +10 -2
  59. klaude_code/tui/components/tools.py +39 -25
  60. klaude_code/tui/components/user_input.py +1 -1
  61. klaude_code/tui/input/__init__.py +5 -2
  62. klaude_code/tui/input/drag_drop.py +6 -57
  63. klaude_code/tui/input/key_bindings.py +10 -0
  64. klaude_code/tui/input/prompt_toolkit.py +19 -6
  65. klaude_code/tui/machine.py +25 -0
  66. klaude_code/tui/renderer.py +68 -4
  67. klaude_code/tui/runner.py +18 -2
  68. klaude_code/tui/terminal/image.py +72 -10
  69. klaude_code/tui/terminal/selector.py +31 -7
  70. {klaude_code-2.7.0.dist-info → klaude_code-2.8.1.dist-info}/METADATA +1 -1
  71. {klaude_code-2.7.0.dist-info → klaude_code-2.8.1.dist-info}/RECORD +73 -56
  72. klaude_code/core/prompts/prompt-codex-gpt-5-1-codex-max.md +0 -117
  73. {klaude_code-2.7.0.dist-info → klaude_code-2.8.1.dist-info}/WHEEL +0 -0
  74. {klaude_code-2.7.0.dist-info → klaude_code-2.8.1.dist-info}/entry_points.txt +0 -0
@@ -364,6 +364,228 @@ def _render_thinking_block(text: str) -> str:
364
364
  return f'<div class="thinking-block markdown-body markdown-content" data-raw="{encoded}"></div>'
365
365
 
366
366
 
367
+ class _TurnGroup:
368
+ def __init__(self) -> None:
369
+ self.user_message: message.UserMessage | None = None
370
+ self.body_items: list[message.HistoryEvent] = []
371
+
372
+
373
+ def _group_messages_by_turn(history: list[message.HistoryEvent]) -> list[_TurnGroup]:
374
+ groups: list[_TurnGroup] = []
375
+ current_group = _TurnGroup()
376
+
377
+ # Filter for renderable items only
378
+ renderable_items = [item for item in history if not isinstance(item, message.ToolResultMessage)]
379
+
380
+ for item in renderable_items:
381
+ if isinstance(item, message.UserMessage):
382
+ # If current group has content, save it and start new
383
+ if current_group.user_message or current_group.body_items:
384
+ groups.append(current_group)
385
+ current_group = _TurnGroup()
386
+ current_group.user_message = item
387
+ else:
388
+ current_group.body_items.append(item)
389
+
390
+ # Append the last group if it has content
391
+ if current_group.user_message or current_group.body_items:
392
+ groups.append(current_group)
393
+
394
+ return groups
395
+
396
+
397
+ def _render_event_item(
398
+ item: message.HistoryEvent,
399
+ tool_results: dict[str, message.ToolResultMessage],
400
+ seen_session_ids: set[str],
401
+ nesting_level: int,
402
+ assistant_counter: list[int],
403
+ ) -> str:
404
+ blocks: list[str] = []
405
+
406
+ if isinstance(item, message.UserMessage):
407
+ text = message.join_text_parts(item.parts)
408
+ images = _extract_image_parts(item.parts)
409
+ images_html = _render_image_parts(images)
410
+ ts_str = _format_msg_timestamp(item.created_at)
411
+ body_parts: list[str] = []
412
+ if images_html:
413
+ body_parts.append(images_html)
414
+ if text:
415
+ body_parts.append(f'<div style="white-space: pre-wrap;">{_escape_html(text)}</div>')
416
+ if not body_parts:
417
+ body_parts.append('<div style="color: var(--text-dim); font-style: italic;">(empty)</div>')
418
+ blocks.append(
419
+ f'<div class="message-group">'
420
+ f'<div class="role-label user">'
421
+ f"User"
422
+ f'<span class="timestamp">{ts_str}</span>'
423
+ f"</div>"
424
+ f'<div class="message-content user">{"".join(body_parts)}</div>'
425
+ f"</div>"
426
+ )
427
+ elif isinstance(item, message.AssistantMessage):
428
+ assistant_counter[0] += 1
429
+ thinking_text = "".join(part.text for part in item.parts if isinstance(part, message.ThinkingTextPart))
430
+ if thinking_text:
431
+ blocks.append(_render_thinking_block(thinking_text))
432
+
433
+ assistant_text = message.join_text_parts(item.parts)
434
+ assistant_images = _extract_image_parts(item.parts)
435
+ if assistant_text or assistant_images:
436
+ blocks.append(
437
+ _render_assistant_message(
438
+ assistant_counter[0],
439
+ assistant_text,
440
+ item.created_at,
441
+ assistant_images,
442
+ )
443
+ )
444
+
445
+ for part in item.parts:
446
+ if isinstance(part, message.ToolCallPart):
447
+ result = tool_results.get(part.call_id)
448
+ blocks.append(_format_tool_call(part, result, item.created_at))
449
+ if result is not None:
450
+ sub_agent_html = _render_sub_agent_session(result, seen_session_ids, nesting_level)
451
+ if sub_agent_html:
452
+ blocks.append(sub_agent_html)
453
+ elif isinstance(item, model.TaskMetadataItem):
454
+ blocks.append(_render_metadata_item(item))
455
+ elif isinstance(item, message.DeveloperMessage):
456
+ content = message.join_text_parts(item.parts)
457
+ images = _extract_image_parts(item.parts)
458
+ images_html = _render_image_parts(images)
459
+ ts_str = _format_msg_timestamp(item.created_at)
460
+
461
+ detail_body = ""
462
+ if images_html:
463
+ detail_body += images_html
464
+ if content:
465
+ detail_body += f'<div style="white-space: pre-wrap;">{_escape_html(content)}</div>'
466
+ if not detail_body:
467
+ detail_body = '<div style="color: var(--text-dim); font-style: italic;">(empty)</div>'
468
+
469
+ blocks.append(
470
+ f'<details class="developer-message gap-below">'
471
+ f"<summary>"
472
+ f"Developer"
473
+ f'<span class="timestamp">{ts_str}</span>'
474
+ f"</summary>"
475
+ f'<div class="details-content">{detail_body}</div>'
476
+ f"</details>"
477
+ )
478
+ elif isinstance(item, message.SystemMessage):
479
+ content = message.join_text_parts(item.parts)
480
+ if content:
481
+ ts_str = _format_msg_timestamp(item.created_at)
482
+ blocks.append(
483
+ f'<details class="developer-message">'
484
+ f"<summary>"
485
+ f"System"
486
+ f'<span class="timestamp">{ts_str}</span>'
487
+ f"</summary>"
488
+ f'<div class="details-content" style="white-space: pre-wrap;">{_escape_html(content)}</div>'
489
+ f"</details>"
490
+ )
491
+
492
+ return "".join(blocks)
493
+
494
+
495
+ def _has_non_mermaid_tool(msg: message.AssistantMessage) -> bool:
496
+ has_non_mermaid = False
497
+ for part in msg.parts:
498
+ if isinstance(part, message.ToolCallPart):
499
+ if part.tool_name != "Mermaid":
500
+ has_non_mermaid = True
501
+ else:
502
+ # If it has Mermaid, we treat it as visible, overriding the non-mermaid flag
503
+ # for the purpose of finding the BARRIER.
504
+ # Logic: We want to find the LAST item that is STRICTLY non-mermaid/intermediate.
505
+ # If an item has Mermaid, it belongs to the visible chain.
506
+ return False
507
+ return has_non_mermaid
508
+
509
+
510
+ def _build_messages_html(
511
+ history: list[message.HistoryEvent],
512
+ tool_results: dict[str, message.ToolResultMessage],
513
+ *,
514
+ seen_session_ids: set[str] | None = None,
515
+ nesting_level: int = 0,
516
+ ) -> str:
517
+ if seen_session_ids is None:
518
+ seen_session_ids = set()
519
+
520
+ blocks: list[str] = []
521
+ assistant_counter = [0] # Use list for mutable reference
522
+
523
+ turns = _group_messages_by_turn(history)
524
+
525
+ for turn in turns:
526
+ # 1. Render User Message
527
+ if turn.user_message:
528
+ blocks.append(
529
+ _render_event_item(turn.user_message, tool_results, seen_session_ids, nesting_level, assistant_counter)
530
+ )
531
+
532
+ if not turn.body_items:
533
+ continue
534
+
535
+ # 2. Identify split point (barrier)
536
+ # Find the LAST AssistantMessage that has a non-Mermaid tool call (and NO Mermaid tool call).
537
+ barrier_index = -1
538
+ for i, item in enumerate(turn.body_items):
539
+ if isinstance(item, message.AssistantMessage) and _has_non_mermaid_tool(item):
540
+ barrier_index = i
541
+
542
+ # If barrier found, everything up to it is collapsible.
543
+ # Everything after is visible.
544
+ # If no barrier (-1), checks depend on if we have any AssistantMessages
545
+ collapsible_items = []
546
+ visible_items = []
547
+
548
+ if barrier_index != -1:
549
+ collapsible_items = turn.body_items[: barrier_index + 1]
550
+ visible_items = turn.body_items[barrier_index + 1 :]
551
+ else:
552
+ # No barrier found (no non-Mermaid tools).
553
+ # If purely conversational, all visible?
554
+ # Or should we fold intermediate chat steps?
555
+ # Current logic: If no tools used, assume chat mode -> All visible.
556
+ collapsible_items = []
557
+ visible_items = turn.body_items
558
+
559
+ # 3. Render Collapsible Items
560
+ if collapsible_items:
561
+ # Count steps (assistant messages + tool calls approx)
562
+ step_count = sum(1 for item in collapsible_items if isinstance(item, message.AssistantMessage))
563
+ step_label = f"{step_count} steps" if step_count != 1 else "1 step"
564
+
565
+ collapsed_html = "".join(
566
+ _render_event_item(item, tool_results, seen_session_ids, nesting_level, assistant_counter)
567
+ for item in collapsible_items
568
+ )
569
+
570
+ blocks.append(
571
+ f'<div class="turn-collapsible">'
572
+ f'<button class="turn-collapse-btn" title="Show/hide intermediate steps">'
573
+ f'<span class="collapse-icon">[+]</span>'
574
+ f'<span class="collapse-count">{step_label}</span>'
575
+ f"</button>"
576
+ f'<div class="turn-steps" style="display: none;">'
577
+ f"{collapsed_html}"
578
+ f"</div>"
579
+ f"</div>"
580
+ )
581
+
582
+ # 4. Render Visible Items
583
+ for item in visible_items:
584
+ blocks.append(_render_event_item(item, tool_results, seen_session_ids, nesting_level, assistant_counter))
585
+
586
+ return "\n".join(blocks)
587
+
588
+
367
589
  def _try_render_todo_args(arguments: str, tool_name: str) -> str | None:
368
590
  try:
369
591
  parsed = json.loads(arguments)
@@ -460,6 +682,31 @@ def _render_sub_agent_result(content: str, description: str | None = None) -> st
460
682
  if images_html and not formatted.strip():
461
683
  return f'<div class="sub-agent-result-container">{images_html}</div>'
462
684
 
685
+ # Check if content needs collapsing (approx > 20 lines or > 2000 chars)
686
+ # Using a simpler metric since we don't know rendered height
687
+ needs_collapse = formatted.count("\n") > 20 or len(formatted) > 2000
688
+
689
+ rendered_html = (
690
+ f'<div class="sub-agent-rendered markdown-content markdown-body" data-raw="{encoded}">'
691
+ f'<noscript><pre style="white-space: pre-wrap;">{encoded}</pre></noscript>'
692
+ f"</div>"
693
+ )
694
+
695
+ if needs_collapse:
696
+ content_html = (
697
+ f'<div class="expandable-markdown">'
698
+ f'<div class="markdown-preview">'
699
+ f"{rendered_html}"
700
+ f"</div>"
701
+ f'<div class="expand-control">'
702
+ f'<button class="expand-btn">Show full output</button>'
703
+ f"</div>"
704
+ f"</div>"
705
+ )
706
+ else:
707
+ # No collapse wrapper needed, but we keep the structure compatible
708
+ content_html = rendered_html
709
+
463
710
  return (
464
711
  f'<div class="sub-agent-result-container">'
465
712
  f"{images_html}"
@@ -468,9 +715,7 @@ def _render_sub_agent_result(content: str, description: str | None = None) -> st
468
715
  f'<button type="button" class="copy-raw-btn" title="Copy raw content">Copy</button>'
469
716
  f"</div>"
470
717
  f'<div class="sub-agent-content">'
471
- f'<div class="sub-agent-rendered markdown-content markdown-body" data-raw="{encoded}">'
472
- f'<noscript><pre style="white-space: pre-wrap;">{encoded}</pre></noscript>'
473
- f"</div>"
718
+ f"{content_html}"
474
719
  f'<pre class="sub-agent-raw">{encoded}</pre>'
475
720
  f"</div>"
476
721
  f"</div>"
@@ -479,21 +724,20 @@ def _render_sub_agent_result(content: str, description: str | None = None) -> st
479
724
 
480
725
  def _render_text_block(text: str) -> str:
481
726
  lines = text.splitlines()
482
- escaped_lines = [_escape_html(line) for line in lines]
727
+ encoded = _escape_html(text)
728
+ content_html = f'<div style="white-space: pre-wrap; font-family: var(--font-mono);">{encoded}</div>'
483
729
 
484
730
  if len(lines) <= _TOOL_OUTPUT_PREVIEW_LINES:
485
- content = "\n".join(escaped_lines)
486
- return f'<div style="white-space: pre-wrap; font-family: var(--font-mono);">{content}</div>'
487
-
488
- preview = "\n".join(escaped_lines[:_TOOL_OUTPUT_PREVIEW_LINES])
489
- full = "\n".join(escaped_lines)
731
+ return content_html
490
732
 
491
733
  return (
492
- f'<div class="expandable-output expandable" style="--preview-max-lines: {_TOOL_OUTPUT_PREVIEW_LINES};">'
493
- f'<div class="preview-text" style="white-space: pre-wrap; font-family: var(--font-mono);">{preview}</div>'
494
- f'<div class="expand-hint expand-text">click to expand full output ({len(lines)} lines; showing first {_TOOL_OUTPUT_PREVIEW_LINES})</div>'
495
- f'<div class="full-text" style="white-space: pre-wrap; font-family: var(--font-mono);">{full}</div>'
496
- f'<div class="collapse-hint">click to collapse</div>'
734
+ f'<div class="expandable-tool-output">'
735
+ f'<div class="tool-output-preview">'
736
+ f"{content_html}"
737
+ f"</div>"
738
+ f'<div class="expand-control">'
739
+ f'<button class="expand-btn">Show full output</button>'
740
+ f"</div>"
497
741
  f"</div>"
498
742
  )
499
743
 
@@ -674,17 +918,51 @@ def _get_mermaid_link_html(
674
918
 
675
919
  # If we have code, render the diagram
676
920
  if code:
677
- return (
678
- f'<div style="background: white; padding: 16px; border-radius: 4px; margin-top: 8px; border: 1px solid var(--border);">'
679
- f'<div class="mermaid">{escaped_code}</div>'
680
- f"{toolbar_html}"
681
- f"</div>"
682
- )
921
+ return f'<div class="mermaid-container"><div class="mermaid">{escaped_code}</div>{toolbar_html}</div>'
683
922
 
684
923
  # Fallback to just link/toolbar if no code available (legacy support behavior)
685
924
  return toolbar_html
686
925
 
687
926
 
927
+ def _format_mermaid_tool_call(
928
+ tool_call: message.ToolCallPart,
929
+ result: message.ToolResultMessage | None,
930
+ timestamp: datetime,
931
+ mermaid_html: str,
932
+ args_html: str,
933
+ ts_str: str,
934
+ ) -> str:
935
+ # Build standard tool details but hidden
936
+ should_collapse = _should_collapse(args_html)
937
+ open_attr = "" if should_collapse else " open"
938
+
939
+ details_html = (
940
+ f'<div class="mermaid-meta" style="display: none;">'
941
+ f'<div class="tool-header">'
942
+ f'<span class="tool-name">{_escape_html(tool_call.tool_name)}</span>'
943
+ f'<div class="tool-header-right">'
944
+ f'<span class="tool-id">{_escape_html(tool_call.call_id)}</span>'
945
+ f'<span class="timestamp">{ts_str}</span>'
946
+ f"</div>"
947
+ f"</div>"
948
+ f'<details class="tool-args-collapsible"{open_attr}>'
949
+ f"<summary>Arguments</summary>"
950
+ f'<div class="tool-args-content">{args_html}</div>'
951
+ f"</details>"
952
+ f"</div>"
953
+ )
954
+
955
+ return (
956
+ f'<div class="tool-call mermaid-tool-call">'
957
+ f'<div class="mermaid-view">'
958
+ f'<button class="mermaid-info-btn" title="Show/Hide Details">i</button>'
959
+ f"{mermaid_html}"
960
+ f"</div>"
961
+ f"{details_html}"
962
+ f"</div>"
963
+ )
964
+
965
+
688
966
  def _format_tool_call(
689
967
  tool_call: message.ToolCallPart,
690
968
  result: message.ToolResultMessage | None,
@@ -711,6 +989,16 @@ def _format_tool_call(
711
989
  if not args_html:
712
990
  args_html = '<span style="color: var(--text-dim); font-style: italic;">(no arguments)</span>'
713
991
 
992
+ # Special handling for Mermaid
993
+ if tool_call.tool_name == "Mermaid" and result:
994
+ extras = _collect_ui_extras(result.ui_extra)
995
+ mermaid_extra = next((x for x in extras if isinstance(x, model.MermaidLinkUIExtra)), None)
996
+ mermaid_source = mermaid_extra if mermaid_extra else result.ui_extra
997
+ mermaid_html = _get_mermaid_link_html(mermaid_source, tool_call)
998
+
999
+ if mermaid_html:
1000
+ return _format_mermaid_tool_call(tool_call, result, timestamp, mermaid_html, args_html, ts_str)
1001
+
714
1002
  # Wrap tool-args with collapsible details element (except for TodoWrite which renders as a list)
715
1003
  if is_todo_list:
716
1004
  args_section = f'<div class="tool-args">{args_html}</div>'
@@ -820,285 +1108,6 @@ def _format_tool_call(
820
1108
  return "".join(html_parts)
821
1109
 
822
1110
 
823
- def _build_messages_html(
824
- history: list[message.HistoryEvent],
825
- tool_results: dict[str, message.ToolResultMessage],
826
- *,
827
- seen_session_ids: set[str] | None = None,
828
- nesting_level: int = 0,
829
- ) -> str:
830
- """Render session history into HTML.
831
-
832
- This export groups the conversation into Tasks:
833
- - A Task starts with a UserMessage.
834
- - The Task includes all subsequent model thinking/replies/tool calls/results until the next UserMessage.
835
- - By default, only the final assistant reply is shown; intermediate steps are folded.
836
- - Mermaid is a special case: show the last consecutive assistant messages + Mermaid diagram result.
837
- """
838
-
839
- if seen_session_ids is None:
840
- seen_session_ids = set()
841
-
842
- renderable_items = [item for item in history if not isinstance(item, message.ToolResultMessage)]
843
- blocks: list[str] = []
844
-
845
- def _render_user_message(item: message.UserMessage) -> str:
846
- text = message.join_text_parts(item.parts)
847
- images = _extract_image_parts(item.parts)
848
- images_html = _render_image_parts(images)
849
- ts_str = _format_msg_timestamp(item.created_at)
850
- body_parts: list[str] = []
851
- if images_html:
852
- body_parts.append(images_html)
853
- if text:
854
- body_parts.append(f'<div style="white-space: pre-wrap;">{_escape_html(text)}</div>')
855
- if not body_parts:
856
- body_parts.append('<div style="color: var(--text-dim); font-style: italic;">(empty)</div>')
857
- return (
858
- f'<div class="message-group">'
859
- f'<div class="role-label user">'
860
- f"User"
861
- f'<span class="timestamp">{ts_str}</span>'
862
- f"</div>"
863
- f'<div class="message-content user">{"".join(body_parts)}</div>'
864
- f"</div>"
865
- )
866
-
867
- def _render_developer_message(item: message.DeveloperMessage, next_item: message.HistoryEvent | None) -> str:
868
- content = message.join_text_parts(item.parts)
869
- images = _extract_image_parts(item.parts)
870
- images_html = _render_image_parts(images)
871
- ts_str = _format_msg_timestamp(item.created_at)
872
-
873
- extra_class = ""
874
- if isinstance(next_item, (message.UserMessage, message.AssistantMessage)):
875
- extra_class = " gap-below"
876
-
877
- detail_body = ""
878
- if images_html:
879
- detail_body += images_html
880
- if content:
881
- detail_body += f'<div style="white-space: pre-wrap;">{_escape_html(content)}</div>'
882
- if not detail_body:
883
- detail_body = '<div style="color: var(--text-dim); font-style: italic;">(empty)</div>'
884
-
885
- return (
886
- f'<details class="developer-message{extra_class}">'
887
- f"<summary>"
888
- f"Developer"
889
- f'<span class="timestamp">{ts_str}</span>'
890
- f"</summary>"
891
- f'<div class="details-content">{detail_body}</div>'
892
- f"</details>"
893
- )
894
-
895
- def _render_system_message(item: message.SystemMessage) -> str | None:
896
- content = message.join_text_parts(item.parts)
897
- if not content:
898
- return None
899
- ts_str = _format_msg_timestamp(item.created_at)
900
- return (
901
- f'<details class="developer-message">'
902
- f"<summary>"
903
- f"System"
904
- f'<span class="timestamp">{ts_str}</span>'
905
- f"</summary>"
906
- f'<div class="details-content" style="white-space: pre-wrap;">{_escape_html(content)}</div>'
907
- f"</details>"
908
- )
909
-
910
- def _render_task_full(task_events: list[message.HistoryEvent]) -> str:
911
- full_parts: list[str] = []
912
- assistant_counter = 0
913
-
914
- for idx, event in enumerate(task_events):
915
- next_event = task_events[idx + 1] if idx + 1 < len(task_events) else None
916
-
917
- if isinstance(event, message.AssistantMessage):
918
- assistant_counter += 1
919
-
920
- thinking_text = "".join(
921
- part.text for part in event.parts if isinstance(part, message.ThinkingTextPart)
922
- ).strip()
923
- if thinking_text:
924
- full_parts.append(_render_thinking_block(thinking_text))
925
-
926
- assistant_text = message.join_text_parts(event.parts)
927
- assistant_images = _extract_image_parts(event.parts)
928
- if assistant_text or assistant_images:
929
- full_parts.append(
930
- _render_assistant_message(
931
- assistant_counter,
932
- assistant_text,
933
- event.created_at,
934
- assistant_images,
935
- )
936
- )
937
-
938
- for part in event.parts:
939
- if isinstance(part, message.ToolCallPart):
940
- result = tool_results.get(part.call_id)
941
- full_parts.append(_format_tool_call(part, result, event.created_at))
942
- if result is not None:
943
- sub_agent_html = _render_sub_agent_session(result, seen_session_ids, nesting_level)
944
- if sub_agent_html:
945
- full_parts.append(sub_agent_html)
946
- elif isinstance(event, model.TaskMetadataItem):
947
- full_parts.append(_render_metadata_item(event))
948
- elif isinstance(event, message.DeveloperMessage):
949
- full_parts.append(_render_developer_message(event, next_event))
950
- elif isinstance(event, message.SystemMessage):
951
- rendered = _render_system_message(event)
952
- if rendered:
953
- full_parts.append(rendered)
954
-
955
- return "\n".join(full_parts)
956
-
957
- def _choose_compact_assistants(
958
- task_events: list[message.HistoryEvent],
959
- ) -> tuple[list[message.AssistantMessage], bool]:
960
- # Mermaid exception: show last consecutive assistant messages + Mermaid diagram result.
961
- tail: list[message.AssistantMessage] = []
962
-
963
- idx = len(task_events) - 1
964
- while idx >= 0 and isinstance(task_events[idx], model.TaskMetadataItem):
965
- idx -= 1
966
-
967
- while idx >= 0 and isinstance(task_events[idx], message.AssistantMessage):
968
- tail.append(cast(message.AssistantMessage, task_events[idx]))
969
- idx -= 1
970
-
971
- tail.reverse()
972
- if tail:
973
- has_mermaid = any(
974
- isinstance(p, message.ToolCallPart) and p.tool_name == "Mermaid" for msg in tail for p in msg.parts
975
- )
976
- if has_mermaid:
977
- return tail, True
978
-
979
- for idx in range(len(task_events) - 1, -1, -1):
980
- if isinstance(task_events[idx], message.AssistantMessage):
981
- return [cast(message.AssistantMessage, task_events[idx])], False
982
-
983
- return [], False
984
-
985
- def _render_assistant_compact(assistant_messages: list[message.AssistantMessage], *, show_mermaid: bool) -> str:
986
- parts: list[str] = []
987
-
988
- for counter, msg in enumerate(assistant_messages, start=1):
989
- assistant_text = message.join_text_parts(msg.parts)
990
- assistant_images = _extract_image_parts(msg.parts)
991
- if assistant_text or assistant_images:
992
- parts.append(_render_assistant_message(counter, assistant_text, msg.created_at, assistant_images))
993
-
994
- if show_mermaid:
995
- # Keep Mermaid diagram interleaved where the tool call occurred.
996
- for p in msg.parts:
997
- if not isinstance(p, message.ToolCallPart) or p.tool_name != "Mermaid":
998
- continue
999
-
1000
- result = tool_results.get(p.call_id)
1001
- ui_extra = result.ui_extra if result else None
1002
- extras = _collect_ui_extras(ui_extra)
1003
- mermaid_extra = next((x for x in extras if isinstance(x, model.MermaidLinkUIExtra)), None)
1004
- mermaid_source = mermaid_extra if mermaid_extra else ui_extra
1005
- mermaid_html = _get_mermaid_link_html(mermaid_source, p)
1006
- if mermaid_html:
1007
- parts.append(f'<div class="task-mermaid-result">{mermaid_html}</div>')
1008
-
1009
- return "\n".join(parts)
1010
-
1011
- def _count_hidden_steps(
1012
- task_events: list[message.HistoryEvent],
1013
- compact_assistants: list[message.AssistantMessage],
1014
- ) -> int:
1015
- compact_ids = {id(x) for x in compact_assistants}
1016
- hidden_assistant_messages = sum(
1017
- 1 for x in task_events if isinstance(x, message.AssistantMessage) and id(x) not in compact_ids
1018
- )
1019
-
1020
- thinking_blocks = 0
1021
- tool_calls = 0
1022
- other_events = 0
1023
- for e in task_events:
1024
- if isinstance(e, message.AssistantMessage):
1025
- for part in e.parts:
1026
- if isinstance(part, message.ThinkingTextPart) and part.text.strip():
1027
- thinking_blocks += 1
1028
- elif isinstance(part, message.ToolCallPart):
1029
- tool_calls += 1
1030
- elif isinstance(e, (model.TaskMetadataItem, message.DeveloperMessage, message.SystemMessage)):
1031
- other_events += 1
1032
-
1033
- return hidden_assistant_messages + thinking_blocks + tool_calls + other_events
1034
-
1035
- def _render_task(user: message.UserMessage, task_events: list[message.HistoryEvent]) -> str:
1036
- compact_assistants, show_mermaid = _choose_compact_assistants(task_events)
1037
- hidden_steps = _count_hidden_steps(task_events, compact_assistants)
1038
-
1039
- full_html = _render_task_full(task_events)
1040
- compact_html = _render_assistant_compact(compact_assistants, show_mermaid=show_mermaid)
1041
-
1042
- if not compact_html.strip() and full_html.strip():
1043
- compact_html = '<div style="color: var(--text-dim); font-style: italic;">(no assistant message)</div>'
1044
-
1045
- steps_details = ""
1046
- if hidden_steps > 0 and full_html.strip():
1047
- steps_details = (
1048
- f'<details class="task-steps" data-step-count="{hidden_steps}">'
1049
- f'<summary data-step-count="{hidden_steps}">Show {hidden_steps} steps</summary>'
1050
- f'<div class="task-steps-content">{full_html}</div>'
1051
- f"</details>"
1052
- )
1053
-
1054
- return (
1055
- '<div class="task">'
1056
- f"{_render_user_message(user)}"
1057
- f"{steps_details}"
1058
- f'<div class="task-final">{compact_html}</div>'
1059
- "</div>"
1060
- )
1061
-
1062
- current_user: message.UserMessage | None = None
1063
- current_task_events: list[message.HistoryEvent] = []
1064
-
1065
- def _flush_task() -> None:
1066
- nonlocal current_user, current_task_events
1067
- if current_user is None:
1068
- return
1069
- blocks.append(_render_task(current_user, current_task_events))
1070
- current_user = None
1071
- current_task_events = []
1072
-
1073
- for idx, item in enumerate(renderable_items):
1074
- if isinstance(item, message.UserMessage):
1075
- _flush_task()
1076
- current_user = item
1077
- current_task_events = []
1078
- continue
1079
-
1080
- if current_user is not None:
1081
- current_task_events.append(item)
1082
- continue
1083
-
1084
- # Events before the first user message.
1085
- next_item = renderable_items[idx + 1] if idx + 1 < len(renderable_items) else None
1086
- if isinstance(item, message.DeveloperMessage):
1087
- blocks.append(_render_developer_message(item, next_item))
1088
- elif isinstance(item, message.SystemMessage):
1089
- rendered = _render_system_message(item)
1090
- if rendered:
1091
- blocks.append(rendered)
1092
- elif isinstance(item, message.AssistantMessage):
1093
- blocks.append(_render_task_full([item]))
1094
- elif isinstance(item, model.TaskMetadataItem):
1095
- blocks.append(_render_metadata_item(item))
1096
-
1097
- _flush_task()
1098
-
1099
- return "\n".join(blocks)
1100
-
1101
-
1102
1111
  def _render_sub_agent_session(
1103
1112
  tool_result: message.ToolResultMessage,
1104
1113
  seen_session_ids: set[str],