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.
- klaude_code/auth/base.py +97 -0
- klaude_code/auth/claude/__init__.py +6 -0
- klaude_code/auth/claude/exceptions.py +9 -0
- klaude_code/auth/claude/oauth.py +172 -0
- klaude_code/auth/claude/token_manager.py +26 -0
- klaude_code/auth/codex/token_manager.py +10 -50
- klaude_code/cli/auth_cmd.py +127 -46
- klaude_code/cli/config_cmd.py +4 -2
- klaude_code/cli/cost_cmd.py +14 -9
- klaude_code/cli/list_model.py +248 -200
- klaude_code/cli/main.py +1 -1
- klaude_code/cli/runtime.py +7 -5
- klaude_code/cli/self_update.py +1 -1
- klaude_code/cli/session_cmd.py +1 -1
- klaude_code/command/clear_cmd.py +6 -2
- klaude_code/command/command_abc.py +2 -2
- klaude_code/command/debug_cmd.py +4 -4
- klaude_code/command/export_cmd.py +2 -2
- klaude_code/command/export_online_cmd.py +12 -12
- klaude_code/command/fork_session_cmd.py +29 -23
- klaude_code/command/help_cmd.py +4 -4
- klaude_code/command/model_cmd.py +4 -4
- klaude_code/command/model_select.py +1 -1
- klaude_code/command/prompt-commit.md +82 -0
- klaude_code/command/prompt_command.py +3 -3
- klaude_code/command/refresh_cmd.py +2 -2
- klaude_code/command/registry.py +7 -5
- klaude_code/command/release_notes_cmd.py +4 -4
- klaude_code/command/resume_cmd.py +15 -11
- klaude_code/command/status_cmd.py +4 -4
- klaude_code/command/terminal_setup_cmd.py +8 -8
- klaude_code/command/thinking_cmd.py +4 -4
- klaude_code/config/assets/builtin_config.yaml +52 -3
- klaude_code/config/builtin_config.py +16 -5
- klaude_code/config/config.py +31 -7
- klaude_code/config/thinking.py +4 -4
- klaude_code/const.py +146 -91
- klaude_code/core/agent.py +3 -12
- klaude_code/core/executor.py +21 -13
- klaude_code/core/manager/sub_agent_manager.py +71 -7
- klaude_code/core/prompt.py +1 -1
- klaude_code/core/prompts/prompt-sub-agent-image-gen.md +1 -0
- klaude_code/core/prompts/prompt-sub-agent-web.md +27 -1
- klaude_code/core/reminders.py +88 -69
- klaude_code/core/task.py +44 -45
- klaude_code/core/tool/file/apply_patch_tool.py +9 -9
- klaude_code/core/tool/file/diff_builder.py +3 -5
- klaude_code/core/tool/file/edit_tool.py +23 -23
- klaude_code/core/tool/file/move_tool.py +43 -43
- klaude_code/core/tool/file/read_tool.py +44 -39
- klaude_code/core/tool/file/write_tool.py +14 -14
- klaude_code/core/tool/report_back_tool.py +4 -4
- klaude_code/core/tool/shell/bash_tool.py +23 -23
- klaude_code/core/tool/skill/skill_tool.py +7 -7
- klaude_code/core/tool/sub_agent_tool.py +38 -9
- klaude_code/core/tool/todo/todo_write_tool.py +8 -8
- klaude_code/core/tool/todo/update_plan_tool.py +6 -6
- klaude_code/core/tool/tool_abc.py +2 -2
- klaude_code/core/tool/tool_context.py +27 -0
- klaude_code/core/tool/tool_runner.py +88 -42
- klaude_code/core/tool/truncation.py +38 -20
- klaude_code/core/tool/web/mermaid_tool.py +6 -7
- klaude_code/core/tool/web/web_fetch_tool.py +68 -30
- klaude_code/core/tool/web/web_search_tool.py +15 -17
- klaude_code/core/turn.py +120 -73
- klaude_code/llm/anthropic/client.py +104 -44
- klaude_code/llm/anthropic/input.py +116 -108
- klaude_code/llm/bedrock/client.py +8 -5
- klaude_code/llm/claude/__init__.py +3 -0
- klaude_code/llm/claude/client.py +105 -0
- klaude_code/llm/client.py +4 -3
- klaude_code/llm/codex/client.py +16 -10
- klaude_code/llm/google/client.py +122 -60
- klaude_code/llm/google/input.py +94 -108
- klaude_code/llm/image.py +123 -0
- klaude_code/llm/input_common.py +136 -189
- klaude_code/llm/openai_compatible/client.py +17 -7
- klaude_code/llm/openai_compatible/input.py +36 -66
- klaude_code/llm/openai_compatible/stream.py +119 -67
- klaude_code/llm/openai_compatible/tool_call_accumulator.py +23 -11
- klaude_code/llm/openrouter/client.py +34 -9
- klaude_code/llm/openrouter/input.py +63 -64
- klaude_code/llm/openrouter/reasoning.py +22 -24
- klaude_code/llm/registry.py +20 -15
- klaude_code/llm/responses/client.py +107 -45
- klaude_code/llm/responses/input.py +115 -98
- klaude_code/llm/usage.py +52 -25
- klaude_code/protocol/__init__.py +1 -0
- klaude_code/protocol/events.py +16 -12
- klaude_code/protocol/llm_param.py +22 -3
- klaude_code/protocol/message.py +250 -0
- klaude_code/protocol/model.py +94 -281
- klaude_code/protocol/op.py +2 -2
- klaude_code/protocol/sub_agent/__init__.py +2 -2
- klaude_code/protocol/sub_agent/explore.py +10 -0
- klaude_code/protocol/sub_agent/image_gen.py +119 -0
- klaude_code/protocol/sub_agent/task.py +10 -0
- klaude_code/protocol/sub_agent/web.py +10 -0
- klaude_code/session/codec.py +6 -6
- klaude_code/session/export.py +261 -62
- klaude_code/session/selector.py +7 -24
- klaude_code/session/session.py +125 -53
- klaude_code/session/store.py +5 -32
- klaude_code/session/templates/export_session.html +1 -1
- klaude_code/session/templates/mermaid_viewer.html +1 -1
- klaude_code/trace/log.py +11 -6
- klaude_code/ui/core/input.py +1 -1
- klaude_code/ui/core/stage_manager.py +1 -8
- klaude_code/ui/modes/debug/display.py +2 -2
- klaude_code/ui/modes/repl/clipboard.py +2 -2
- klaude_code/ui/modes/repl/completers.py +18 -10
- klaude_code/ui/modes/repl/event_handler.py +136 -127
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +1 -1
- klaude_code/ui/modes/repl/key_bindings.py +1 -1
- klaude_code/ui/modes/repl/renderer.py +107 -15
- klaude_code/ui/renderers/assistant.py +2 -2
- klaude_code/ui/renderers/common.py +65 -7
- klaude_code/ui/renderers/developer.py +7 -6
- klaude_code/ui/renderers/diffs.py +11 -11
- klaude_code/ui/renderers/mermaid_viewer.py +49 -2
- klaude_code/ui/renderers/metadata.py +39 -31
- klaude_code/ui/renderers/sub_agent.py +57 -16
- klaude_code/ui/renderers/thinking.py +37 -2
- klaude_code/ui/renderers/tools.py +180 -165
- klaude_code/ui/rich/live.py +3 -1
- klaude_code/ui/rich/markdown.py +39 -7
- klaude_code/ui/rich/quote.py +76 -1
- klaude_code/ui/rich/status.py +14 -8
- klaude_code/ui/rich/theme.py +13 -6
- klaude_code/ui/terminal/image.py +34 -0
- klaude_code/ui/terminal/notifier.py +2 -1
- klaude_code/ui/terminal/progress_bar.py +4 -4
- klaude_code/ui/terminal/selector.py +22 -4
- klaude_code/ui/utils/common.py +55 -0
- {klaude_code-1.8.0.dist-info → klaude_code-2.0.0.dist-info}/METADATA +28 -6
- klaude_code-2.0.0.dist-info/RECORD +229 -0
- klaude_code/command/prompt-jj-describe.md +0 -32
- klaude_code/core/prompts/prompt-sub-agent-oracle.md +0 -22
- klaude_code/protocol/sub_agent/oracle.py +0 -91
- klaude_code-1.8.0.dist-info/RECORD +0 -219
- {klaude_code-1.8.0.dist-info → klaude_code-2.0.0.dist-info}/WHEEL +0 -0
- {klaude_code-1.8.0.dist-info → klaude_code-2.0.0.dist-info}/entry_points.txt +0 -0
klaude_code/session/export.py
CHANGED
|
@@ -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[
|
|
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,
|
|
56
|
-
content = item.
|
|
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(
|
|
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
|
-
#
|
|
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(
|
|
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 =
|
|
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:
|
|
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.
|
|
632
|
+
if not code and tool_call and tool_call.tool_name == "Mermaid":
|
|
492
633
|
try:
|
|
493
|
-
args = json.loads(tool_call.
|
|
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(
|
|
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(
|
|
695
|
+
ts_str = _format_msg_timestamp(timestamp)
|
|
551
696
|
|
|
552
|
-
if tool_call.
|
|
553
|
-
args_html = _try_render_todo_args(tool_call.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
723
|
+
if tool_call.tool_name == "Memory":
|
|
579
724
|
try:
|
|
580
|
-
parsed_args = json.loads(tool_call.
|
|
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.
|
|
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.
|
|
759
|
+
should_hide_text = tool_call.tool_name in ("TodoWrite", "update_plan") and result.status != "error"
|
|
615
760
|
|
|
616
761
|
if (
|
|
617
|
-
tool_call.
|
|
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.
|
|
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
|
-
|
|
634
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 =
|
|
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[
|
|
673
|
-
tool_results: dict[str,
|
|
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,
|
|
690
|
-
text =
|
|
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"
|
|
857
|
+
f'<div class="message-content user">{"".join(body_parts)}</div>'
|
|
699
858
|
f"</div>"
|
|
700
859
|
)
|
|
701
|
-
elif isinstance(item,
|
|
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
|
-
|
|
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,
|
|
710
|
-
content =
|
|
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, (
|
|
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"
|
|
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:
|
|
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,
|
|
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,
|
|
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(
|
|
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
|
|
klaude_code/session/selector.py
CHANGED
|
@@ -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
|
|
8
|
-
"""Format timestamp as
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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=
|
|
76
|
+
relative_time=_format_time(s.updated_at),
|
|
94
77
|
model_name=model,
|
|
95
78
|
)
|
|
96
79
|
)
|