klaude-code 1.2.2__py3-none-any.whl → 1.2.4__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/cli/main.py +7 -0
- klaude_code/cli/runtime.py +6 -6
- klaude_code/command/__init__.py +9 -5
- klaude_code/command/clear_cmd.py +3 -24
- klaude_code/command/command_abc.py +36 -1
- klaude_code/command/export_cmd.py +16 -20
- klaude_code/command/help_cmd.py +1 -0
- klaude_code/command/model_cmd.py +3 -30
- klaude_code/command/{prompt-update-dev-doc.md → prompt-dev-docs-update.md} +3 -2
- klaude_code/command/{prompt-dev-doc.md → prompt-dev-docs.md} +3 -2
- klaude_code/command/prompt-init.md +2 -5
- klaude_code/command/prompt_command.py +3 -3
- klaude_code/command/registry.py +6 -7
- klaude_code/command/status_cmd.py +111 -0
- klaude_code/config/config.py +1 -1
- klaude_code/config/list_model.py +1 -1
- klaude_code/const/__init__.py +1 -1
- klaude_code/core/agent.py +2 -11
- klaude_code/core/executor.py +155 -14
- klaude_code/core/prompts/prompt-gemini.md +1 -1
- klaude_code/core/reminders.py +24 -0
- klaude_code/core/task.py +10 -0
- klaude_code/core/tool/shell/bash_tool.py +6 -2
- klaude_code/core/tool/sub_agent_tool.py +1 -1
- klaude_code/core/tool/tool_context.py +1 -1
- klaude_code/core/tool/tool_registry.py +1 -1
- klaude_code/core/tool/tool_runner.py +1 -1
- klaude_code/core/tool/web/mermaid_tool.py +1 -1
- klaude_code/llm/__init__.py +3 -4
- klaude_code/llm/anthropic/client.py +12 -9
- klaude_code/llm/openai_compatible/client.py +2 -18
- klaude_code/llm/openai_compatible/tool_call_accumulator.py +2 -2
- klaude_code/llm/openrouter/client.py +2 -18
- klaude_code/llm/openrouter/input.py +6 -2
- klaude_code/llm/registry.py +2 -71
- klaude_code/llm/responses/client.py +2 -0
- klaude_code/llm/{metadata_tracker.py → usage.py} +49 -2
- klaude_code/protocol/commands.py +1 -0
- klaude_code/protocol/llm_param.py +12 -0
- klaude_code/protocol/model.py +30 -3
- klaude_code/protocol/op.py +14 -14
- klaude_code/protocol/op_handler.py +28 -0
- klaude_code/protocol/tools.py +0 -2
- klaude_code/session/export.py +124 -35
- klaude_code/session/session.py +1 -1
- klaude_code/session/templates/export_session.html +383 -39
- klaude_code/ui/__init__.py +6 -2
- klaude_code/ui/modes/exec/display.py +26 -0
- klaude_code/ui/modes/repl/event_handler.py +5 -1
- klaude_code/ui/renderers/developer.py +62 -11
- klaude_code/ui/renderers/metadata.py +33 -24
- klaude_code/ui/renderers/sub_agent.py +1 -1
- klaude_code/ui/renderers/tools.py +2 -2
- klaude_code/ui/renderers/user_input.py +18 -22
- klaude_code/ui/rich/status.py +13 -2
- {klaude_code-1.2.2.dist-info → klaude_code-1.2.4.dist-info}/METADATA +1 -1
- {klaude_code-1.2.2.dist-info → klaude_code-1.2.4.dist-info}/RECORD +60 -58
- /klaude_code/{core → protocol}/sub_agent.py +0 -0
- {klaude_code-1.2.2.dist-info → klaude_code-1.2.4.dist-info}/WHEEL +0 -0
- {klaude_code-1.2.2.dist-info → klaude_code-1.2.4.dist-info}/entry_points.txt +0 -0
|
@@ -1,16 +1,42 @@
|
|
|
1
1
|
import time
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import openai.types
|
|
4
|
+
|
|
5
|
+
from klaude_code.protocol import llm_param, model
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def calculate_cost(usage: model.Usage, cost_config: llm_param.Cost | None) -> None:
|
|
9
|
+
"""Calculate and set cost fields on usage based on cost configuration.
|
|
10
|
+
|
|
11
|
+
Note: input_tokens includes cached_tokens, so we need to subtract cached_tokens
|
|
12
|
+
to get the actual non-cached input tokens for cost calculation.
|
|
13
|
+
"""
|
|
14
|
+
if cost_config is None:
|
|
15
|
+
return
|
|
16
|
+
|
|
17
|
+
# Non-cached input tokens cost
|
|
18
|
+
non_cached_input = usage.input_tokens - usage.cached_tokens
|
|
19
|
+
usage.input_cost = (non_cached_input / 1_000_000) * cost_config.input
|
|
20
|
+
|
|
21
|
+
# Output tokens cost (includes reasoning tokens)
|
|
22
|
+
usage.output_cost = (usage.output_tokens / 1_000_000) * cost_config.output
|
|
23
|
+
|
|
24
|
+
# Cache read cost
|
|
25
|
+
usage.cache_read_cost = (usage.cached_tokens / 1_000_000) * cost_config.cache_read
|
|
26
|
+
|
|
27
|
+
# Total cost
|
|
28
|
+
usage.total_cost = usage.input_cost + usage.output_cost + usage.cache_read_cost
|
|
4
29
|
|
|
5
30
|
|
|
6
31
|
class MetadataTracker:
|
|
7
32
|
"""Tracks timing and metadata for LLM responses."""
|
|
8
33
|
|
|
9
|
-
def __init__(self) -> None:
|
|
34
|
+
def __init__(self, cost_config: llm_param.Cost | None = None) -> None:
|
|
10
35
|
self._request_start_time: float = time.time()
|
|
11
36
|
self._first_token_time: float | None = None
|
|
12
37
|
self._last_token_time: float | None = None
|
|
13
38
|
self._metadata_item = model.ResponseMetadataItem()
|
|
39
|
+
self._cost_config = cost_config
|
|
14
40
|
|
|
15
41
|
@property
|
|
16
42
|
def metadata_item(self) -> model.ResponseMetadataItem:
|
|
@@ -59,4 +85,25 @@ class MetadataTracker:
|
|
|
59
85
|
if time_duration >= 0.15:
|
|
60
86
|
self._metadata_item.usage.throughput_tps = self._metadata_item.usage.output_tokens / time_duration
|
|
61
87
|
|
|
88
|
+
# Calculate cost if config is available
|
|
89
|
+
if self._metadata_item.usage:
|
|
90
|
+
calculate_cost(self._metadata_item.usage, self._cost_config)
|
|
91
|
+
|
|
62
92
|
return self._metadata_item
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def convert_usage(usage: openai.types.CompletionUsage, context_limit: int | None = None) -> model.Usage:
|
|
96
|
+
"""Convert OpenAI CompletionUsage to internal Usage model."""
|
|
97
|
+
total_tokens = usage.total_tokens
|
|
98
|
+
context_usage_percent = (total_tokens / context_limit) * 100 if context_limit else None
|
|
99
|
+
return model.Usage(
|
|
100
|
+
input_tokens=usage.prompt_tokens,
|
|
101
|
+
cached_tokens=(usage.prompt_tokens_details.cached_tokens if usage.prompt_tokens_details else 0) or 0,
|
|
102
|
+
reasoning_tokens=(usage.completion_tokens_details.reasoning_tokens if usage.completion_tokens_details else 0)
|
|
103
|
+
or 0,
|
|
104
|
+
output_tokens=usage.completion_tokens,
|
|
105
|
+
total_tokens=total_tokens,
|
|
106
|
+
context_usage_percent=context_usage_percent,
|
|
107
|
+
throughput_tps=None,
|
|
108
|
+
first_token_latency_ms=None,
|
|
109
|
+
)
|
klaude_code/protocol/commands.py
CHANGED
|
@@ -11,6 +11,7 @@ class CommandName(str, Enum):
|
|
|
11
11
|
CLEAR = "clear"
|
|
12
12
|
TERMINAL_SETUP = "terminal-setup"
|
|
13
13
|
EXPORT = "export"
|
|
14
|
+
STATUS = "status"
|
|
14
15
|
# PLAN and DOC are dynamically registered now, but kept here if needed for reference
|
|
15
16
|
# or we can remove them if no code explicitly imports them.
|
|
16
17
|
# PLAN = "plan"
|
|
@@ -35,6 +35,15 @@ class Thinking(BaseModel):
|
|
|
35
35
|
budget_tokens: int | None = None
|
|
36
36
|
|
|
37
37
|
|
|
38
|
+
class Cost(BaseModel):
|
|
39
|
+
"""Cost configuration per million tokens (USD)."""
|
|
40
|
+
|
|
41
|
+
input: float # Input token price per million tokens
|
|
42
|
+
output: float # Output token price per million tokens
|
|
43
|
+
cache_read: float = 0.0 # Cache read price per million tokens
|
|
44
|
+
cache_write: float = 0.0 # Cache write price per million tokens (ignored in calculation for now)
|
|
45
|
+
|
|
46
|
+
|
|
38
47
|
class OpenRouterProviderRouting(BaseModel):
|
|
39
48
|
"""
|
|
40
49
|
https://openrouter.ai/docs/features/provider-routing#json-schema-for-provider-preferences
|
|
@@ -99,6 +108,9 @@ class LLMConfigModelParameter(BaseModel):
|
|
|
99
108
|
# OpenRouter Provider Routing Preferences
|
|
100
109
|
provider_routing: OpenRouterProviderRouting | None = None
|
|
101
110
|
|
|
111
|
+
# Cost configuration (USD per million tokens)
|
|
112
|
+
cost: Cost | None = None
|
|
113
|
+
|
|
102
114
|
|
|
103
115
|
class LLMConfigParameter(LLMConfigProviderParameter, LLMConfigModelParameter):
|
|
104
116
|
"""
|
klaude_code/protocol/model.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
from datetime import datetime
|
|
1
2
|
from enum import Enum
|
|
2
3
|
from typing import Literal
|
|
3
4
|
|
|
4
|
-
from pydantic import BaseModel
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
5
6
|
|
|
6
7
|
from klaude_code.protocol.commands import CommandName
|
|
7
8
|
from klaude_code.protocol.tools import SubAgentType
|
|
@@ -20,6 +21,12 @@ class Usage(BaseModel):
|
|
|
20
21
|
throughput_tps: float | None = None
|
|
21
22
|
first_token_latency_ms: float | None = None
|
|
22
23
|
|
|
24
|
+
# Cost in USD (calculated from token counts and cost config)
|
|
25
|
+
input_cost: float | None = None # Cost for non-cached input tokens
|
|
26
|
+
output_cost: float | None = None # Cost for output tokens (including reasoning)
|
|
27
|
+
cache_read_cost: float | None = None # Cost for cached tokens
|
|
28
|
+
total_cost: float | None = None # Total cost (input + output + cache_read)
|
|
29
|
+
|
|
23
30
|
|
|
24
31
|
class TodoItem(BaseModel):
|
|
25
32
|
content: str
|
|
@@ -38,6 +45,7 @@ class ToolResultUIExtraType(str, Enum):
|
|
|
38
45
|
SESSION_ID = "session_id"
|
|
39
46
|
MERMAID_LINK = "mermaid_link"
|
|
40
47
|
TRUNCATION = "truncation"
|
|
48
|
+
SESSION_STATUS = "session_status"
|
|
41
49
|
|
|
42
50
|
|
|
43
51
|
class ToolSideEffect(str, Enum):
|
|
@@ -55,6 +63,11 @@ class TruncationUIExtra(BaseModel):
|
|
|
55
63
|
truncated_length: int
|
|
56
64
|
|
|
57
65
|
|
|
66
|
+
class SessionStatusUIExtra(BaseModel):
|
|
67
|
+
usage: "Usage"
|
|
68
|
+
task_count: int
|
|
69
|
+
|
|
70
|
+
|
|
58
71
|
class ToolResultUIExtra(BaseModel):
|
|
59
72
|
type: ToolResultUIExtraType
|
|
60
73
|
diff_text: str | None = None
|
|
@@ -62,6 +75,7 @@ class ToolResultUIExtra(BaseModel):
|
|
|
62
75
|
session_id: str | None = None
|
|
63
76
|
mermaid_link: MermaidLinkUIExtra | None = None
|
|
64
77
|
truncation: TruncationUIExtra | None = None
|
|
78
|
+
session_status: SessionStatusUIExtra | None = None
|
|
65
79
|
|
|
66
80
|
|
|
67
81
|
class AtPatternParseResult(BaseModel):
|
|
@@ -113,16 +127,18 @@ When adding a new item, please also modify the following:
|
|
|
113
127
|
|
|
114
128
|
class StartItem(BaseModel):
|
|
115
129
|
response_id: str
|
|
130
|
+
created_at: datetime = Field(default_factory=datetime.now)
|
|
116
131
|
|
|
117
132
|
|
|
118
133
|
class InterruptItem(BaseModel):
|
|
119
|
-
|
|
134
|
+
created_at: datetime = Field(default_factory=datetime.now)
|
|
120
135
|
|
|
121
136
|
|
|
122
137
|
class SystemMessageItem(BaseModel):
|
|
123
138
|
id: str | None = None
|
|
124
139
|
role: RoleType = "system"
|
|
125
140
|
content: str | None = None
|
|
141
|
+
created_at: datetime = Field(default_factory=datetime.now)
|
|
126
142
|
|
|
127
143
|
|
|
128
144
|
class DeveloperMessageItem(BaseModel):
|
|
@@ -130,6 +146,7 @@ class DeveloperMessageItem(BaseModel):
|
|
|
130
146
|
role: RoleType = "developer"
|
|
131
147
|
content: str | None = None # For LLM input
|
|
132
148
|
images: list["ImageURLPart"] | None = None
|
|
149
|
+
created_at: datetime = Field(default_factory=datetime.now)
|
|
133
150
|
|
|
134
151
|
# Special fields for reminders UI
|
|
135
152
|
memory_paths: list[str] | None = None
|
|
@@ -137,7 +154,7 @@ class DeveloperMessageItem(BaseModel):
|
|
|
137
154
|
todo_use: bool | None = None
|
|
138
155
|
at_files: list[AtPatternParseResult] | None = None
|
|
139
156
|
command_output: CommandOutput | None = None
|
|
140
|
-
|
|
157
|
+
user_image_count: int | None = None
|
|
141
158
|
|
|
142
159
|
|
|
143
160
|
class ImageURLPart(BaseModel):
|
|
@@ -164,6 +181,7 @@ class UserMessageItem(BaseModel):
|
|
|
164
181
|
role: RoleType = "user"
|
|
165
182
|
content: str | None = None
|
|
166
183
|
images: list[ImageURLPart] | None = None
|
|
184
|
+
created_at: datetime = Field(default_factory=datetime.now)
|
|
167
185
|
|
|
168
186
|
|
|
169
187
|
class AssistantMessageItem(BaseModel):
|
|
@@ -171,6 +189,7 @@ class AssistantMessageItem(BaseModel):
|
|
|
171
189
|
role: RoleType = "assistant"
|
|
172
190
|
content: str | None = None
|
|
173
191
|
response_id: str | None = None
|
|
192
|
+
created_at: datetime = Field(default_factory=datetime.now)
|
|
174
193
|
|
|
175
194
|
|
|
176
195
|
class ReasoningTextItem(BaseModel):
|
|
@@ -178,6 +197,7 @@ class ReasoningTextItem(BaseModel):
|
|
|
178
197
|
response_id: str | None = None
|
|
179
198
|
content: str
|
|
180
199
|
model: str | None = None
|
|
200
|
+
created_at: datetime = Field(default_factory=datetime.now)
|
|
181
201
|
|
|
182
202
|
|
|
183
203
|
class ReasoningEncryptedItem(BaseModel):
|
|
@@ -186,6 +206,7 @@ class ReasoningEncryptedItem(BaseModel):
|
|
|
186
206
|
encrypted_content: str # OpenAI encrypted content or Anthropic thinking signature
|
|
187
207
|
format: str | None = None
|
|
188
208
|
model: str | None
|
|
209
|
+
created_at: datetime = Field(default_factory=datetime.now)
|
|
189
210
|
|
|
190
211
|
|
|
191
212
|
class ToolCallStartItem(BaseModel):
|
|
@@ -198,6 +219,7 @@ class ToolCallStartItem(BaseModel):
|
|
|
198
219
|
response_id: str | None = None
|
|
199
220
|
call_id: str
|
|
200
221
|
name: str
|
|
222
|
+
created_at: datetime = Field(default_factory=datetime.now)
|
|
201
223
|
|
|
202
224
|
|
|
203
225
|
class ToolCallItem(BaseModel):
|
|
@@ -206,6 +228,7 @@ class ToolCallItem(BaseModel):
|
|
|
206
228
|
call_id: str
|
|
207
229
|
name: str
|
|
208
230
|
arguments: str
|
|
231
|
+
created_at: datetime = Field(default_factory=datetime.now)
|
|
209
232
|
|
|
210
233
|
|
|
211
234
|
class ToolResultItem(BaseModel):
|
|
@@ -216,15 +239,18 @@ class ToolResultItem(BaseModel):
|
|
|
216
239
|
ui_extra: ToolResultUIExtra | None = None # Extra data for UI display, e.g. diff render
|
|
217
240
|
images: list[ImageURLPart] | None = None
|
|
218
241
|
side_effects: list[ToolSideEffect] | None = None
|
|
242
|
+
created_at: datetime = Field(default_factory=datetime.now)
|
|
219
243
|
|
|
220
244
|
|
|
221
245
|
class AssistantMessageDelta(BaseModel):
|
|
222
246
|
response_id: str | None = None
|
|
223
247
|
content: str
|
|
248
|
+
created_at: datetime = Field(default_factory=datetime.now)
|
|
224
249
|
|
|
225
250
|
|
|
226
251
|
class StreamErrorItem(BaseModel):
|
|
227
252
|
error: str
|
|
253
|
+
created_at: datetime = Field(default_factory=datetime.now)
|
|
228
254
|
|
|
229
255
|
|
|
230
256
|
class ResponseMetadataItem(BaseModel):
|
|
@@ -235,6 +261,7 @@ class ResponseMetadataItem(BaseModel):
|
|
|
235
261
|
task_duration_s: float | None = None
|
|
236
262
|
status: str | None = None
|
|
237
263
|
error_reason: str | None = None
|
|
264
|
+
created_at: datetime = Field(default_factory=datetime.now)
|
|
238
265
|
|
|
239
266
|
|
|
240
267
|
MessageItem = (
|
klaude_code/protocol/op.py
CHANGED
|
@@ -5,7 +5,8 @@ This module defines the operation types and submission structure
|
|
|
5
5
|
that the executor uses to handle different types of requests.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
from
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
9
10
|
from enum import Enum
|
|
10
11
|
from typing import TYPE_CHECKING
|
|
11
12
|
from uuid import uuid4
|
|
@@ -15,7 +16,7 @@ from pydantic import BaseModel, Field
|
|
|
15
16
|
from klaude_code.protocol.model import UserInputPayload
|
|
16
17
|
|
|
17
18
|
if TYPE_CHECKING:
|
|
18
|
-
from klaude_code.
|
|
19
|
+
from klaude_code.protocol.op_handler import OperationHandler
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
class OperationType(Enum):
|
|
@@ -27,16 +28,15 @@ class OperationType(Enum):
|
|
|
27
28
|
END = "end"
|
|
28
29
|
|
|
29
30
|
|
|
30
|
-
class Operation(BaseModel
|
|
31
|
+
class Operation(BaseModel):
|
|
31
32
|
"""Base class for all operations that can be submitted to the executor."""
|
|
32
33
|
|
|
33
34
|
type: OperationType
|
|
34
35
|
id: str = Field(default_factory=lambda: uuid4().hex)
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"
|
|
39
|
-
pass
|
|
37
|
+
async def execute(self, handler: OperationHandler) -> None:
|
|
38
|
+
"""Execute this operation using the given handler."""
|
|
39
|
+
raise NotImplementedError("Subclasses must implement execute()")
|
|
40
40
|
|
|
41
41
|
|
|
42
42
|
class UserInputOperation(Operation):
|
|
@@ -46,9 +46,9 @@ class UserInputOperation(Operation):
|
|
|
46
46
|
input: UserInputPayload
|
|
47
47
|
session_id: str | None = None
|
|
48
48
|
|
|
49
|
-
async def execute(self,
|
|
49
|
+
async def execute(self, handler: OperationHandler) -> None:
|
|
50
50
|
"""Execute user input by running it through an agent."""
|
|
51
|
-
await
|
|
51
|
+
await handler.handle_user_input(self)
|
|
52
52
|
|
|
53
53
|
|
|
54
54
|
class InterruptOperation(Operation):
|
|
@@ -57,9 +57,9 @@ class InterruptOperation(Operation):
|
|
|
57
57
|
type: OperationType = OperationType.INTERRUPT
|
|
58
58
|
target_session_id: str | None = None # If None, interrupt all sessions
|
|
59
59
|
|
|
60
|
-
async def execute(self,
|
|
60
|
+
async def execute(self, handler: OperationHandler) -> None:
|
|
61
61
|
"""Execute interrupt by cancelling active tasks."""
|
|
62
|
-
await
|
|
62
|
+
await handler.handle_interrupt(self)
|
|
63
63
|
|
|
64
64
|
|
|
65
65
|
class InitAgentOperation(Operation):
|
|
@@ -68,8 +68,8 @@ class InitAgentOperation(Operation):
|
|
|
68
68
|
type: OperationType = OperationType.INIT_AGENT
|
|
69
69
|
session_id: str | None = None
|
|
70
70
|
|
|
71
|
-
async def execute(self,
|
|
72
|
-
await
|
|
71
|
+
async def execute(self, handler: OperationHandler) -> None:
|
|
72
|
+
await handler.handle_init_agent(self)
|
|
73
73
|
|
|
74
74
|
|
|
75
75
|
class EndOperation(Operation):
|
|
@@ -77,7 +77,7 @@ class EndOperation(Operation):
|
|
|
77
77
|
|
|
78
78
|
type: OperationType = OperationType.END
|
|
79
79
|
|
|
80
|
-
async def execute(self,
|
|
80
|
+
async def execute(self, handler: OperationHandler) -> None:
|
|
81
81
|
"""Execute end operation - this is a no-op, just signals the executor to stop."""
|
|
82
82
|
pass
|
|
83
83
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Operation handler protocol for the executor system.
|
|
3
|
+
|
|
4
|
+
This module defines the protocol that operation handlers must implement.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING, Protocol
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from klaude_code.protocol.op import InitAgentOperation, InterruptOperation, UserInputOperation
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OperationHandler(Protocol):
|
|
16
|
+
"""Protocol defining the interface for handling operations."""
|
|
17
|
+
|
|
18
|
+
async def handle_user_input(self, operation: UserInputOperation) -> None:
|
|
19
|
+
"""Handle a user input operation."""
|
|
20
|
+
...
|
|
21
|
+
|
|
22
|
+
async def handle_interrupt(self, operation: InterruptOperation) -> None:
|
|
23
|
+
"""Handle an interrupt operation."""
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
async def handle_init_agent(self, operation: InitAgentOperation) -> None:
|
|
27
|
+
"""Handle an init agent operation."""
|
|
28
|
+
...
|
klaude_code/protocol/tools.py
CHANGED
klaude_code/session/export.py
CHANGED
|
@@ -11,8 +11,8 @@ from pathlib import Path
|
|
|
11
11
|
from string import Template
|
|
12
12
|
from typing import TYPE_CHECKING, Any, Final, cast
|
|
13
13
|
|
|
14
|
-
from klaude_code.core.sub_agent import is_sub_agent_tool
|
|
15
14
|
from klaude_code.protocol import llm_param, model
|
|
15
|
+
from klaude_code.protocol.sub_agent import is_sub_agent_tool
|
|
16
16
|
|
|
17
17
|
if TYPE_CHECKING:
|
|
18
18
|
from klaude_code.session.session import Session
|
|
@@ -45,6 +45,10 @@ def _format_timestamp(value: float | None) -> str:
|
|
|
45
45
|
return datetime.fromtimestamp(value).strftime("%Y-%m-%d %H:%M:%S")
|
|
46
46
|
|
|
47
47
|
|
|
48
|
+
def _format_msg_timestamp(dt: datetime) -> str:
|
|
49
|
+
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
|
50
|
+
|
|
51
|
+
|
|
48
52
|
def get_first_user_message(history: list[model.ConversationItem]) -> str:
|
|
49
53
|
"""Extract the first user message content from conversation history."""
|
|
50
54
|
for item in history:
|
|
@@ -150,6 +154,10 @@ def _format_token_count(count: int) -> str:
|
|
|
150
154
|
return f"{m}M" if rem == 0 else f"{m}M{rem}k"
|
|
151
155
|
|
|
152
156
|
|
|
157
|
+
def _format_cost(cost: float) -> str:
|
|
158
|
+
return f"${cost:.4f}"
|
|
159
|
+
|
|
160
|
+
|
|
153
161
|
def _render_metadata_item(item: model.ResponseMetadataItem) -> str:
|
|
154
162
|
# Line 1: Model Name [@ Provider]
|
|
155
163
|
model_parts = [f'<span class="metadata-model">{_escape_html(item.model_name)}</span>']
|
|
@@ -163,10 +171,25 @@ def _render_metadata_item(item: model.ResponseMetadataItem) -> str:
|
|
|
163
171
|
stats_parts: list[str] = []
|
|
164
172
|
if item.usage:
|
|
165
173
|
u = item.usage
|
|
166
|
-
|
|
174
|
+
# Input with cost
|
|
175
|
+
input_stat = f"input: {_format_token_count(u.input_tokens)}"
|
|
176
|
+
if u.input_cost is not None:
|
|
177
|
+
input_stat += f"({_format_cost(u.input_cost)})"
|
|
178
|
+
stats_parts.append(f'<span class="metadata-stat">{input_stat}</span>')
|
|
179
|
+
|
|
180
|
+
# Cached with cost
|
|
167
181
|
if u.cached_tokens > 0:
|
|
168
|
-
|
|
169
|
-
|
|
182
|
+
cached_stat = f"cached: {_format_token_count(u.cached_tokens)}"
|
|
183
|
+
if u.cache_read_cost is not None:
|
|
184
|
+
cached_stat += f"({_format_cost(u.cache_read_cost)})"
|
|
185
|
+
stats_parts.append(f'<span class="metadata-stat">{cached_stat}</span>')
|
|
186
|
+
|
|
187
|
+
# Output with cost
|
|
188
|
+
output_stat = f"output: {_format_token_count(u.output_tokens)}"
|
|
189
|
+
if u.output_cost is not None:
|
|
190
|
+
output_stat += f"({_format_cost(u.output_cost)})"
|
|
191
|
+
stats_parts.append(f'<span class="metadata-stat">{output_stat}</span>')
|
|
192
|
+
|
|
170
193
|
if u.reasoning_tokens > 0:
|
|
171
194
|
stats_parts.append(
|
|
172
195
|
f'<span class="metadata-stat">thinking: {_format_token_count(u.reasoning_tokens)}</span>'
|
|
@@ -177,7 +200,11 @@ def _render_metadata_item(item: model.ResponseMetadataItem) -> str:
|
|
|
177
200
|
stats_parts.append(f'<span class="metadata-stat">tps: {u.throughput_tps:.1f}</span>')
|
|
178
201
|
|
|
179
202
|
if item.task_duration_s is not None:
|
|
180
|
-
stats_parts.append(f'<span class="metadata-stat">
|
|
203
|
+
stats_parts.append(f'<span class="metadata-stat">time: {item.task_duration_s:.1f}s</span>')
|
|
204
|
+
|
|
205
|
+
# Total cost
|
|
206
|
+
if item.usage is not None and item.usage.total_cost is not None:
|
|
207
|
+
stats_parts.append(f'<span class="metadata-stat">cost: {_format_cost(item.usage.total_cost)}</span>')
|
|
181
208
|
|
|
182
209
|
stats_html = ""
|
|
183
210
|
if stats_parts:
|
|
@@ -192,13 +219,15 @@ def _render_metadata_item(item: model.ResponseMetadataItem) -> str:
|
|
|
192
219
|
)
|
|
193
220
|
|
|
194
221
|
|
|
195
|
-
def _render_assistant_message(index: int, content: str) -> str:
|
|
222
|
+
def _render_assistant_message(index: int, content: str, timestamp: datetime) -> str:
|
|
196
223
|
encoded = _escape_html(content)
|
|
224
|
+
ts_str = _format_msg_timestamp(timestamp)
|
|
197
225
|
return (
|
|
198
226
|
f'<div class="message-group assistant-message-group">'
|
|
199
227
|
f'<div class="message-header">'
|
|
200
228
|
f'<div class="role-label assistant">Assistant</div>'
|
|
201
229
|
f'<div class="assistant-toolbar">'
|
|
230
|
+
f'<span class="timestamp">{ts_str}</span>'
|
|
202
231
|
f'<button type="button" class="raw-toggle" aria-pressed="false" title="Toggle raw text view">Raw</button>'
|
|
203
232
|
f'<button type="button" class="copy-raw-btn" title="Copy raw content">Copy</button>'
|
|
204
233
|
f"</div>"
|
|
@@ -276,9 +305,9 @@ def _render_text_block(text: str) -> str:
|
|
|
276
305
|
return (
|
|
277
306
|
f'<div class="expandable-output expandable">'
|
|
278
307
|
f'<div class="preview-text" style="white-space: pre-wrap; font-family: var(--font-mono);">{preview}</div>'
|
|
279
|
-
f'<div class="expand-hint expand-text">
|
|
308
|
+
f'<div class="expand-hint expand-text">click to expand full output ({len(lines)} lines)</div>'
|
|
280
309
|
f'<div class="full-text" style="white-space: pre-wrap; font-family: var(--font-mono);">{full}</div>'
|
|
281
|
-
f'<div class="collapse-hint">
|
|
310
|
+
f'<div class="collapse-hint">click to collapse</div>'
|
|
282
311
|
f"</div>"
|
|
283
312
|
)
|
|
284
313
|
|
|
@@ -324,40 +353,74 @@ def _get_diff_text(ui_extra: model.ToolResultUIExtra | None) -> str | None:
|
|
|
324
353
|
def _get_mermaid_link_html(
|
|
325
354
|
ui_extra: model.ToolResultUIExtra | None, tool_call: model.ToolCallItem | None = None
|
|
326
355
|
) -> str | None:
|
|
327
|
-
if ui_extra is None:
|
|
328
|
-
return None
|
|
329
|
-
if ui_extra.type != model.ToolResultUIExtraType.MERMAID_LINK:
|
|
330
|
-
return None
|
|
331
|
-
if ui_extra.mermaid_link is None or not ui_extra.mermaid_link.link:
|
|
332
|
-
return None
|
|
333
|
-
link = _escape_html(ui_extra.mermaid_link.link)
|
|
334
|
-
lines = ui_extra.mermaid_link.line_count
|
|
335
|
-
|
|
336
|
-
copy_btn = ""
|
|
337
356
|
if tool_call and tool_call.name == "Mermaid":
|
|
338
357
|
try:
|
|
339
358
|
args = json.loads(tool_call.arguments)
|
|
340
|
-
code = args.get("code")
|
|
341
|
-
if code:
|
|
342
|
-
escaped_code = _escape_html(code)
|
|
343
|
-
copy_btn = f'<button type="button" class="copy-mermaid-btn" data-code="{escaped_code}" title="Copy Mermaid Code">Copy Code</button>'
|
|
359
|
+
code = args.get("code", "")
|
|
344
360
|
except Exception:
|
|
345
|
-
|
|
361
|
+
code = ""
|
|
362
|
+
else:
|
|
363
|
+
code = ""
|
|
346
364
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
365
|
+
if not code and (
|
|
366
|
+
ui_extra is None or ui_extra.type != model.ToolResultUIExtraType.MERMAID_LINK or not ui_extra.mermaid_link
|
|
367
|
+
):
|
|
368
|
+
return None
|
|
369
|
+
|
|
370
|
+
# Prepare code for rendering and copy
|
|
371
|
+
escaped_code = _escape_html(code) if code else ""
|
|
372
|
+
line_count = code.count("\n") + 1 if code else 0
|
|
373
|
+
|
|
374
|
+
# Build Toolbar
|
|
375
|
+
toolbar_items: list[str] = []
|
|
376
|
+
|
|
377
|
+
if line_count > 0:
|
|
378
|
+
toolbar_items.append(f"<span>Lines: {line_count}</span>")
|
|
379
|
+
|
|
380
|
+
buttons_html: list[str] = []
|
|
381
|
+
if code:
|
|
382
|
+
buttons_html.append(
|
|
383
|
+
f'<button type="button" class="copy-mermaid-btn" data-code="{escaped_code}" title="Copy Mermaid Code">Copy Code</button>'
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
link = (
|
|
387
|
+
ui_extra.mermaid_link.link
|
|
388
|
+
if (ui_extra and ui_extra.type == model.ToolResultUIExtraType.MERMAID_LINK and ui_extra.mermaid_link)
|
|
389
|
+
else None
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
if link:
|
|
393
|
+
link_url = _escape_html(link)
|
|
394
|
+
buttons_html.append(
|
|
395
|
+
f'<a href="{link_url}" target="_blank" rel="noopener noreferrer" style="color: var(--accent); text-decoration: underline; margin-left: 8px;">View Online</a>'
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
toolbar_items.append(f"<div>{''.join(buttons_html)}</div>")
|
|
399
|
+
|
|
400
|
+
toolbar_html = (
|
|
401
|
+
'<div style="display: flex; justify-content: space-between; align-items: center; font-family: var(--font-mono); margin-top: 8px; padding-top: 8px; border-top: 1px dashed var(--border);">'
|
|
402
|
+
f"{''.join(toolbar_items)}"
|
|
354
403
|
"</div>"
|
|
355
404
|
)
|
|
356
405
|
|
|
406
|
+
# If we have code, render the diagram
|
|
407
|
+
if code:
|
|
408
|
+
return (
|
|
409
|
+
f'<div style="background: white; padding: 16px; border-radius: 4px; margin-top: 8px; border: 1px solid var(--border);">'
|
|
410
|
+
f'<div class="mermaid">{escaped_code}</div>'
|
|
411
|
+
f"{toolbar_html}"
|
|
412
|
+
f"</div>"
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
# Fallback to just link/toolbar if no code available (legacy support behavior)
|
|
416
|
+
return toolbar_html
|
|
417
|
+
|
|
357
418
|
|
|
358
419
|
def _format_tool_call(tool_call: model.ToolCallItem, result: model.ToolResultItem | None) -> str:
|
|
359
420
|
args_html = None
|
|
360
421
|
is_todo_list = False
|
|
422
|
+
ts_str = _format_msg_timestamp(tool_call.created_at)
|
|
423
|
+
|
|
361
424
|
if tool_call.name == "TodoWrite":
|
|
362
425
|
args_html = _try_render_todo_args(tool_call.arguments)
|
|
363
426
|
if args_html:
|
|
@@ -379,7 +442,21 @@ def _format_tool_call(tool_call: model.ToolCallItem, result: model.ToolResultIte
|
|
|
379
442
|
if is_todo_list:
|
|
380
443
|
args_section = f'<div class="tool-args">{args_html}</div>'
|
|
381
444
|
else:
|
|
382
|
-
|
|
445
|
+
# Always collapse Mermaid, Edit, Write tools by default
|
|
446
|
+
always_collapse_tools = {"Mermaid", "Edit", "Write"}
|
|
447
|
+
force_collapse = tool_call.name in always_collapse_tools
|
|
448
|
+
|
|
449
|
+
# Collapse Memory tool for write operations
|
|
450
|
+
if tool_call.name == "Memory":
|
|
451
|
+
try:
|
|
452
|
+
parsed_args = json.loads(tool_call.arguments)
|
|
453
|
+
if parsed_args.get("command") in {"create", "str_replace", "insert"}:
|
|
454
|
+
force_collapse = True
|
|
455
|
+
except Exception:
|
|
456
|
+
pass
|
|
457
|
+
|
|
458
|
+
should_collapse = force_collapse or _should_collapse(args_html)
|
|
459
|
+
open_attr = "" if should_collapse else " open"
|
|
383
460
|
args_section = (
|
|
384
461
|
f'<details class="tool-args-collapsible"{open_attr}>'
|
|
385
462
|
"<summary>Arguments</summary>"
|
|
@@ -391,7 +468,10 @@ def _format_tool_call(tool_call: model.ToolCallItem, result: model.ToolResultIte
|
|
|
391
468
|
'<div class="tool-call">',
|
|
392
469
|
'<div class="tool-header">',
|
|
393
470
|
f'<span class="tool-name">{_escape_html(tool_call.name)}</span>',
|
|
471
|
+
'<div class="tool-header-right">',
|
|
394
472
|
f'<span class="tool-id">{_escape_html(tool_call.call_id)}</span>',
|
|
473
|
+
f'<span class="timestamp">{ts_str}</span>',
|
|
474
|
+
"</div>",
|
|
395
475
|
"</div>",
|
|
396
476
|
args_section,
|
|
397
477
|
]
|
|
@@ -455,22 +535,27 @@ def _build_messages_html(
|
|
|
455
535
|
for i, item in enumerate(renderable_items):
|
|
456
536
|
if isinstance(item, model.UserMessageItem):
|
|
457
537
|
text = _escape_html(item.content or "")
|
|
538
|
+
ts_str = _format_msg_timestamp(item.created_at)
|
|
458
539
|
blocks.append(
|
|
459
540
|
f'<div class="message-group">'
|
|
460
|
-
f'<div class="role-label user">
|
|
541
|
+
f'<div class="role-label user">'
|
|
542
|
+
f"User"
|
|
543
|
+
f'<span class="timestamp">{ts_str}</span>'
|
|
544
|
+
f"</div>"
|
|
461
545
|
f'<div class="message-content user" style="white-space: pre-wrap;">{text}</div>'
|
|
462
546
|
f"</div>"
|
|
463
547
|
)
|
|
464
548
|
elif isinstance(item, model.ReasoningTextItem):
|
|
465
549
|
text = _escape_html(item.content.strip())
|
|
466
|
-
blocks.append(f'<div class="thinking-block"
|
|
550
|
+
blocks.append(f'<div class="thinking-block markdown-body markdown-content" data-raw="{text}"></div>')
|
|
467
551
|
elif isinstance(item, model.AssistantMessageItem):
|
|
468
552
|
assistant_counter += 1
|
|
469
|
-
blocks.append(_render_assistant_message(assistant_counter, item.content or ""))
|
|
553
|
+
blocks.append(_render_assistant_message(assistant_counter, item.content or "", item.created_at))
|
|
470
554
|
elif isinstance(item, model.ResponseMetadataItem):
|
|
471
555
|
blocks.append(_render_metadata_item(item))
|
|
472
556
|
elif isinstance(item, model.DeveloperMessageItem):
|
|
473
557
|
content = _escape_html(item.content or "")
|
|
558
|
+
ts_str = _format_msg_timestamp(item.created_at)
|
|
474
559
|
|
|
475
560
|
next_item = renderable_items[i + 1] if i + 1 < len(renderable_items) else None
|
|
476
561
|
extra_class = ""
|
|
@@ -479,10 +564,14 @@ def _build_messages_html(
|
|
|
479
564
|
|
|
480
565
|
blocks.append(
|
|
481
566
|
f'<details class="developer-message{extra_class}">'
|
|
482
|
-
f"<summary>
|
|
567
|
+
f"<summary>"
|
|
568
|
+
f"Developer"
|
|
569
|
+
f'<span class="timestamp">{ts_str}</span>'
|
|
570
|
+
f"</summary>"
|
|
483
571
|
f'<div class="details-content" style="white-space: pre-wrap;">{content}</div>'
|
|
484
572
|
f"</details>"
|
|
485
573
|
)
|
|
574
|
+
|
|
486
575
|
elif isinstance(item, model.ToolCallItem):
|
|
487
576
|
result = tool_results.get(item.call_id)
|
|
488
577
|
blocks.append(_format_tool_call(item, result))
|
klaude_code/session/session.py
CHANGED
|
@@ -204,7 +204,7 @@ class Session(BaseModel):
|
|
|
204
204
|
for it in items:
|
|
205
205
|
# Serialize with explicit type tag for reliable load
|
|
206
206
|
t = it.__class__.__name__
|
|
207
|
-
data = it.model_dump()
|
|
207
|
+
data = it.model_dump(mode="json")
|
|
208
208
|
f.write(json.dumps({"type": t, "data": data}, ensure_ascii=False))
|
|
209
209
|
f.write("\n")
|
|
210
210
|
# Refresh metadata timestamp after history change
|