klaude-code 2.5.2__py3-none-any.whl → 2.5.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- klaude_code/cli/auth_cmd.py +2 -13
- klaude_code/cli/cost_cmd.py +10 -10
- klaude_code/cli/main.py +40 -7
- klaude_code/cli/session_cmd.py +2 -11
- klaude_code/config/assets/builtin_config.yaml +45 -24
- klaude_code/config/model_matcher.py +1 -1
- klaude_code/const.py +2 -1
- klaude_code/core/tool/file/edit_tool.py +1 -1
- klaude_code/core/tool/file/read_tool.py +2 -2
- klaude_code/core/tool/file/write_tool.py +1 -1
- klaude_code/core/turn.py +19 -1
- klaude_code/llm/anthropic/client.py +75 -50
- klaude_code/llm/anthropic/input.py +20 -9
- klaude_code/llm/google/client.py +223 -148
- klaude_code/llm/google/input.py +44 -36
- klaude_code/llm/openai_compatible/stream.py +109 -99
- klaude_code/llm/openrouter/reasoning.py +4 -29
- klaude_code/llm/partial_message.py +2 -32
- klaude_code/llm/responses/client.py +99 -81
- klaude_code/llm/responses/input.py +11 -25
- klaude_code/llm/stream_parts.py +94 -0
- klaude_code/log.py +57 -0
- klaude_code/tui/command/fork_session_cmd.py +14 -23
- klaude_code/tui/command/model_picker.py +2 -17
- klaude_code/tui/command/resume_cmd.py +2 -18
- klaude_code/tui/command/sub_agent_model_cmd.py +5 -19
- klaude_code/tui/command/thinking_cmd.py +2 -14
- klaude_code/tui/components/common.py +1 -1
- klaude_code/tui/components/metadata.py +17 -16
- klaude_code/tui/components/rich/quote.py +36 -8
- klaude_code/tui/components/rich/theme.py +2 -0
- klaude_code/tui/input/prompt_toolkit.py +3 -1
- klaude_code/tui/machine.py +19 -1
- klaude_code/tui/renderer.py +3 -3
- klaude_code/tui/terminal/selector.py +174 -31
- {klaude_code-2.5.2.dist-info → klaude_code-2.5.3.dist-info}/METADATA +1 -1
- {klaude_code-2.5.2.dist-info → klaude_code-2.5.3.dist-info}/RECORD +39 -38
- {klaude_code-2.5.2.dist-info → klaude_code-2.5.3.dist-info}/WHEEL +0 -0
- {klaude_code-2.5.2.dist-info → klaude_code-2.5.3.dist-info}/entry_points.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import json
|
|
2
2
|
from collections.abc import AsyncGenerator
|
|
3
|
-
from typing import TYPE_CHECKING,
|
|
3
|
+
from typing import TYPE_CHECKING, override
|
|
4
4
|
|
|
5
5
|
import httpx
|
|
6
6
|
import openai
|
|
@@ -11,9 +11,14 @@ from openai.types.responses.response_create_params import ResponseCreateParamsSt
|
|
|
11
11
|
from klaude_code.const import LLM_HTTP_TIMEOUT_CONNECT, LLM_HTTP_TIMEOUT_READ, LLM_HTTP_TIMEOUT_TOTAL
|
|
12
12
|
from klaude_code.llm.client import LLMClientABC, LLMStreamABC
|
|
13
13
|
from klaude_code.llm.input_common import apply_config_defaults
|
|
14
|
-
from klaude_code.llm.partial_message import degrade_thinking_to_text
|
|
15
14
|
from klaude_code.llm.registry import register
|
|
16
15
|
from klaude_code.llm.responses.input import convert_history_to_input, convert_tool_schema
|
|
16
|
+
from klaude_code.llm.stream_parts import (
|
|
17
|
+
append_text_part,
|
|
18
|
+
append_thinking_text_part,
|
|
19
|
+
build_partial_message,
|
|
20
|
+
build_partial_parts,
|
|
21
|
+
)
|
|
17
22
|
from klaude_code.llm.usage import MetadataTracker, error_llm_stream
|
|
18
23
|
from klaude_code.log import DebugType, log_debug
|
|
19
24
|
from klaude_code.protocol import llm_param, message, model
|
|
@@ -58,68 +63,82 @@ def build_payload(param: llm_param.LLMCallParameter) -> ResponseCreateParamsStre
|
|
|
58
63
|
|
|
59
64
|
|
|
60
65
|
class ResponsesStreamStateManager:
|
|
61
|
-
"""Manages streaming state for Responses API and provides partial message access.
|
|
66
|
+
"""Manages streaming state for Responses API and provides partial message access.
|
|
67
|
+
|
|
68
|
+
Accumulates parts directly during streaming to support get_partial_message()
|
|
69
|
+
for cancellation scenarios. Merges consecutive text parts of the same type.
|
|
70
|
+
Each reasoning summary is kept as a separate ThinkingTextPart.
|
|
71
|
+
"""
|
|
62
72
|
|
|
63
73
|
def __init__(self, model_id: str) -> None:
|
|
64
74
|
self.model_id = model_id
|
|
65
75
|
self.response_id: str | None = None
|
|
66
|
-
self.stage: Literal["waiting", "thinking", "assistant", "tool"] = "waiting"
|
|
67
|
-
self.accumulated_thinking: list[str] = []
|
|
68
|
-
self.accumulated_text: list[str] = []
|
|
69
|
-
self.pending_signature: str | None = None
|
|
70
76
|
self.assistant_parts: list[message.Part] = []
|
|
71
77
|
self.stop_reason: model.StopReason | None = None
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
self._new_thinking_part: bool = True # Start fresh for first thinking part
|
|
79
|
+
self._summary_count: int = 0 # Track number of summary parts seen
|
|
80
|
+
|
|
81
|
+
def start_new_thinking_part(self) -> bool:
|
|
82
|
+
"""Mark that the next thinking text should create a new ThinkingTextPart.
|
|
83
|
+
|
|
84
|
+
Returns True if this is not the first summary part (needs separator).
|
|
85
|
+
"""
|
|
86
|
+
self._new_thinking_part = True
|
|
87
|
+
needs_separator = self._summary_count > 0
|
|
88
|
+
self._summary_count += 1
|
|
89
|
+
return needs_separator
|
|
90
|
+
|
|
91
|
+
def append_thinking_text(self, text: str) -> None:
|
|
92
|
+
"""Append thinking text, merging with previous ThinkingTextPart if in same summary."""
|
|
93
|
+
if (
|
|
94
|
+
append_thinking_text_part(
|
|
95
|
+
self.assistant_parts,
|
|
96
|
+
text,
|
|
97
|
+
model_id=self.model_id,
|
|
98
|
+
force_new=self._new_thinking_part,
|
|
81
99
|
)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
self.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
100
|
+
is not None
|
|
101
|
+
):
|
|
102
|
+
self._new_thinking_part = False
|
|
103
|
+
|
|
104
|
+
def append_text(self, text: str) -> None:
|
|
105
|
+
"""Append text, merging with previous TextPart if possible."""
|
|
106
|
+
append_text_part(self.assistant_parts, text)
|
|
107
|
+
|
|
108
|
+
def append_thinking_signature(self, signature: str) -> None:
|
|
109
|
+
"""Append a ThinkingSignaturePart after the current part."""
|
|
110
|
+
self.assistant_parts.append(
|
|
111
|
+
message.ThinkingSignaturePart(
|
|
112
|
+
signature=signature,
|
|
113
|
+
model_id=self.model_id,
|
|
114
|
+
format="openai-responses",
|
|
90
115
|
)
|
|
91
|
-
|
|
116
|
+
)
|
|
92
117
|
|
|
93
|
-
def
|
|
94
|
-
"""
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
118
|
+
def append_tool_call(self, call_id: str, item_id: str | None, name: str, arguments_json: str) -> None:
|
|
119
|
+
"""Append a ToolCallPart."""
|
|
120
|
+
self.assistant_parts.append(
|
|
121
|
+
message.ToolCallPart(
|
|
122
|
+
call_id=call_id,
|
|
123
|
+
id=item_id,
|
|
124
|
+
tool_name=name,
|
|
125
|
+
arguments_json=arguments_json,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
99
128
|
|
|
100
|
-
def
|
|
101
|
-
"""
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
129
|
+
def get_partial_parts(self) -> list[message.Part]:
|
|
130
|
+
"""Get accumulated parts excluding tool calls, with thinking degraded.
|
|
131
|
+
|
|
132
|
+
Filters out ToolCallPart and applies degrade_thinking_to_text.
|
|
133
|
+
"""
|
|
134
|
+
return build_partial_parts(self.assistant_parts)
|
|
105
135
|
|
|
106
136
|
def get_partial_message(self) -> message.AssistantMessage | None:
|
|
107
|
-
"""Build a partial AssistantMessage from accumulated state.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
continue
|
|
113
|
-
filtered_parts.append(part)
|
|
114
|
-
|
|
115
|
-
filtered_parts = degrade_thinking_to_text(filtered_parts)
|
|
116
|
-
if not filtered_parts:
|
|
117
|
-
return None
|
|
118
|
-
return message.AssistantMessage(
|
|
119
|
-
parts=filtered_parts,
|
|
120
|
-
response_id=self.response_id,
|
|
121
|
-
stop_reason="aborted",
|
|
122
|
-
)
|
|
137
|
+
"""Build a partial AssistantMessage from accumulated state.
|
|
138
|
+
|
|
139
|
+
Returns None if no content has been accumulated yet.
|
|
140
|
+
"""
|
|
141
|
+
return build_partial_message(self.assistant_parts, response_id=self.response_id)
|
|
123
142
|
|
|
124
143
|
|
|
125
144
|
async def parse_responses_stream(
|
|
@@ -157,24 +176,28 @@ async def parse_responses_stream(
|
|
|
157
176
|
match event:
|
|
158
177
|
case responses.ResponseCreatedEvent() as event:
|
|
159
178
|
state.response_id = event.response.id
|
|
179
|
+
case responses.ResponseReasoningSummaryPartAddedEvent():
|
|
180
|
+
# New reasoning summary part started, ensure it becomes a new ThinkingTextPart
|
|
181
|
+
needs_separator = state.start_new_thinking_part()
|
|
182
|
+
if needs_separator:
|
|
183
|
+
# Add blank lines between summary parts for visual separation
|
|
184
|
+
yield message.ThinkingTextDelta(content=" \n \n", response_id=state.response_id)
|
|
160
185
|
case responses.ResponseReasoningSummaryTextDeltaEvent() as event:
|
|
161
186
|
if event.delta:
|
|
162
187
|
metadata_tracker.record_token()
|
|
163
|
-
|
|
164
|
-
state.flush_text()
|
|
165
|
-
state.stage = "thinking"
|
|
166
|
-
state.accumulated_thinking.append(event.delta)
|
|
188
|
+
state.append_thinking_text(event.delta)
|
|
167
189
|
yield message.ThinkingTextDelta(content=event.delta, response_id=state.response_id)
|
|
168
190
|
case responses.ResponseReasoningSummaryTextDoneEvent() as event:
|
|
169
|
-
if
|
|
170
|
-
|
|
191
|
+
# Fallback: if no delta was received but done has full text, use it
|
|
192
|
+
if event.text:
|
|
193
|
+
# Check if we already have content for this summary by seeing if last part matches
|
|
194
|
+
last_part = state.assistant_parts[-1] if state.assistant_parts else None
|
|
195
|
+
if not isinstance(last_part, message.ThinkingTextPart) or not last_part.text:
|
|
196
|
+
state.append_thinking_text(event.text)
|
|
171
197
|
case responses.ResponseTextDeltaEvent() as event:
|
|
172
198
|
if event.delta:
|
|
173
199
|
metadata_tracker.record_token()
|
|
174
|
-
|
|
175
|
-
state.flush_thinking()
|
|
176
|
-
state.stage = "assistant"
|
|
177
|
-
state.accumulated_text.append(event.delta)
|
|
200
|
+
state.append_text(event.delta)
|
|
178
201
|
yield message.AssistantTextDelta(content=event.delta, response_id=state.response_id)
|
|
179
202
|
case responses.ResponseOutputItemAddedEvent() as event:
|
|
180
203
|
if isinstance(event.item, responses.ResponseFunctionToolCall):
|
|
@@ -188,30 +211,23 @@ async def parse_responses_stream(
|
|
|
188
211
|
match event.item:
|
|
189
212
|
case responses.ResponseReasoningItem() as item:
|
|
190
213
|
if item.encrypted_content:
|
|
191
|
-
state.
|
|
214
|
+
state.append_thinking_signature(item.encrypted_content)
|
|
192
215
|
case responses.ResponseOutputMessage() as item:
|
|
193
|
-
if
|
|
216
|
+
# Fallback: if no text delta was received, extract from final message
|
|
217
|
+
has_text = any(isinstance(p, message.TextPart) for p in state.assistant_parts)
|
|
218
|
+
if not has_text:
|
|
194
219
|
text_content = "\n".join(
|
|
195
|
-
|
|
196
|
-
part.text
|
|
197
|
-
for part in item.content
|
|
198
|
-
if isinstance(part, responses.ResponseOutputText)
|
|
199
|
-
]
|
|
220
|
+
part.text for part in item.content if isinstance(part, responses.ResponseOutputText)
|
|
200
221
|
)
|
|
201
222
|
if text_content:
|
|
202
|
-
state.
|
|
223
|
+
state.append_text(text_content)
|
|
203
224
|
case responses.ResponseFunctionToolCall() as item:
|
|
204
225
|
metadata_tracker.record_token()
|
|
205
|
-
state.
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
call_id=item.call_id,
|
|
211
|
-
id=item.id,
|
|
212
|
-
tool_name=item.name,
|
|
213
|
-
arguments_json=item.arguments.strip(),
|
|
214
|
-
)
|
|
226
|
+
state.append_tool_call(
|
|
227
|
+
call_id=item.call_id,
|
|
228
|
+
item_id=item.id,
|
|
229
|
+
name=item.name,
|
|
230
|
+
arguments_json=item.arguments.strip(),
|
|
215
231
|
)
|
|
216
232
|
case _:
|
|
217
233
|
pass
|
|
@@ -254,10 +270,12 @@ async def parse_responses_stream(
|
|
|
254
270
|
)
|
|
255
271
|
except (openai.OpenAIError, httpx.HTTPError) as e:
|
|
256
272
|
yield message.StreamErrorItem(error=f"{e.__class__.__name__} {e!s}")
|
|
273
|
+
state.stop_reason = "error"
|
|
257
274
|
|
|
258
|
-
parts = state.flush_all()
|
|
259
275
|
metadata_tracker.set_response_id(state.response_id)
|
|
260
276
|
metadata = metadata_tracker.finalize()
|
|
277
|
+
# On error, use partial parts (excluding incomplete tool calls) for potential prefill on retry
|
|
278
|
+
parts = state.get_partial_parts() if state.stop_reason == "error" else list(state.assistant_parts)
|
|
261
279
|
yield message.AssistantMessage(
|
|
262
280
|
parts=parts,
|
|
263
281
|
response_id=state.response_id,
|
|
@@ -80,8 +80,6 @@ def convert_history_to_input(
|
|
|
80
80
|
"""Convert a list of messages to response input params."""
|
|
81
81
|
items: list[responses.ResponseInputItemParam] = []
|
|
82
82
|
|
|
83
|
-
degraded_thinking_texts: list[str] = []
|
|
84
|
-
|
|
85
83
|
for msg, attachment in attach_developer_messages(history):
|
|
86
84
|
match msg:
|
|
87
85
|
case message.SystemMessage():
|
|
@@ -116,12 +114,19 @@ def convert_history_to_input(
|
|
|
116
114
|
case message.ToolResultMessage():
|
|
117
115
|
items.append(_build_tool_result_item(msg, attachment))
|
|
118
116
|
case message.AssistantMessage():
|
|
119
|
-
assistant_text_parts: list[responses.
|
|
117
|
+
assistant_text_parts: list[responses.ResponseOutputTextParam] = []
|
|
120
118
|
pending_thinking_text: str | None = None
|
|
121
119
|
pending_signature: str | None = None
|
|
122
120
|
native_thinking_parts, degraded_for_message = split_thinking_parts(msg, model_name)
|
|
123
121
|
native_thinking_ids = {id(part) for part in native_thinking_parts}
|
|
124
|
-
|
|
122
|
+
if degraded_for_message:
|
|
123
|
+
degraded_text = "<thinking>\n" + "\n".join(degraded_for_message) + "\n</thinking>"
|
|
124
|
+
assistant_text_parts.append(
|
|
125
|
+
cast(
|
|
126
|
+
responses.ResponseOutputTextParam,
|
|
127
|
+
{"type": "output_text", "text": degraded_text},
|
|
128
|
+
)
|
|
129
|
+
)
|
|
125
130
|
|
|
126
131
|
def flush_text() -> None:
|
|
127
132
|
nonlocal assistant_text_parts
|
|
@@ -164,8 +169,8 @@ def convert_history_to_input(
|
|
|
164
169
|
if isinstance(part, message.TextPart):
|
|
165
170
|
assistant_text_parts.append(
|
|
166
171
|
cast(
|
|
167
|
-
responses.
|
|
168
|
-
{"type": "
|
|
172
|
+
responses.ResponseOutputTextParam,
|
|
173
|
+
{"type": "output_text", "text": part.text},
|
|
169
174
|
)
|
|
170
175
|
)
|
|
171
176
|
elif isinstance(part, message.ToolCallPart):
|
|
@@ -188,25 +193,6 @@ def convert_history_to_input(
|
|
|
188
193
|
case _:
|
|
189
194
|
continue
|
|
190
195
|
|
|
191
|
-
if degraded_thinking_texts:
|
|
192
|
-
degraded_item = cast(
|
|
193
|
-
responses.ResponseInputItemParam,
|
|
194
|
-
{
|
|
195
|
-
"type": "message",
|
|
196
|
-
"role": "assistant",
|
|
197
|
-
"content": [
|
|
198
|
-
cast(
|
|
199
|
-
responses.ResponseInputContentParam,
|
|
200
|
-
{
|
|
201
|
-
"type": "input_text",
|
|
202
|
-
"text": "<thinking>\n" + "\n".join(degraded_thinking_texts) + "\n</thinking>",
|
|
203
|
-
},
|
|
204
|
-
)
|
|
205
|
-
],
|
|
206
|
-
},
|
|
207
|
-
)
|
|
208
|
-
items.insert(0, degraded_item)
|
|
209
|
-
|
|
210
196
|
return items
|
|
211
197
|
|
|
212
198
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import MutableSequence
|
|
4
|
+
|
|
5
|
+
from klaude_code.protocol import message
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def append_text_part(parts: MutableSequence[message.Part], text: str) -> int | None:
|
|
9
|
+
if not text:
|
|
10
|
+
return None
|
|
11
|
+
|
|
12
|
+
if parts:
|
|
13
|
+
last = parts[-1]
|
|
14
|
+
if isinstance(last, message.TextPart):
|
|
15
|
+
parts[-1] = message.TextPart(text=last.text + text)
|
|
16
|
+
return len(parts) - 1
|
|
17
|
+
|
|
18
|
+
parts.append(message.TextPart(text=text))
|
|
19
|
+
return len(parts) - 1
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def append_thinking_text_part(
|
|
23
|
+
parts: MutableSequence[message.Part],
|
|
24
|
+
text: str,
|
|
25
|
+
*,
|
|
26
|
+
model_id: str,
|
|
27
|
+
force_new: bool = False,
|
|
28
|
+
) -> int | None:
|
|
29
|
+
if not text:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
if not force_new and parts:
|
|
33
|
+
last = parts[-1]
|
|
34
|
+
if isinstance(last, message.ThinkingTextPart):
|
|
35
|
+
parts[-1] = message.ThinkingTextPart(
|
|
36
|
+
text=last.text + text,
|
|
37
|
+
model_id=model_id,
|
|
38
|
+
)
|
|
39
|
+
return len(parts) - 1
|
|
40
|
+
|
|
41
|
+
parts.append(message.ThinkingTextPart(text=text, model_id=model_id))
|
|
42
|
+
return len(parts) - 1
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def degrade_thinking_to_text(parts: list[message.Part]) -> list[message.Part]:
|
|
46
|
+
"""Degrade thinking parts into a regular TextPart.
|
|
47
|
+
|
|
48
|
+
Some providers require thinking signatures/encrypted content to be echoed back
|
|
49
|
+
for subsequent calls. During interruption we cannot reliably determine whether
|
|
50
|
+
we have a complete signature, so we persist thinking as plain text instead.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
thinking_texts: list[str] = []
|
|
54
|
+
non_thinking_parts: list[message.Part] = []
|
|
55
|
+
|
|
56
|
+
for part in parts:
|
|
57
|
+
if isinstance(part, message.ThinkingTextPart):
|
|
58
|
+
text = part.text
|
|
59
|
+
if text and text.strip():
|
|
60
|
+
thinking_texts.append(text)
|
|
61
|
+
continue
|
|
62
|
+
if isinstance(part, message.ThinkingSignaturePart):
|
|
63
|
+
continue
|
|
64
|
+
non_thinking_parts.append(part)
|
|
65
|
+
|
|
66
|
+
if not thinking_texts:
|
|
67
|
+
return non_thinking_parts
|
|
68
|
+
|
|
69
|
+
joined = "\n".join(thinking_texts).strip()
|
|
70
|
+
thinking_block = f"<thinking>\n{joined}\n</thinking>"
|
|
71
|
+
if non_thinking_parts:
|
|
72
|
+
thinking_block += "\n\n"
|
|
73
|
+
|
|
74
|
+
return [message.TextPart(text=thinking_block), *non_thinking_parts]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def build_partial_parts(parts: list[message.Part]) -> list[message.Part]:
|
|
78
|
+
filtered_parts: list[message.Part] = [p for p in parts if not isinstance(p, message.ToolCallPart)]
|
|
79
|
+
return degrade_thinking_to_text(filtered_parts)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def build_partial_message(
|
|
83
|
+
parts: list[message.Part],
|
|
84
|
+
*,
|
|
85
|
+
response_id: str | None,
|
|
86
|
+
) -> message.AssistantMessage | None:
|
|
87
|
+
partial_parts = build_partial_parts(parts)
|
|
88
|
+
if not partial_parts:
|
|
89
|
+
return None
|
|
90
|
+
return message.AssistantMessage(
|
|
91
|
+
parts=partial_parts,
|
|
92
|
+
response_id=response_id,
|
|
93
|
+
stop_reason="aborted",
|
|
94
|
+
)
|
klaude_code/log.py
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import gzip
|
|
2
|
+
import json
|
|
2
3
|
import logging
|
|
3
4
|
import os
|
|
4
5
|
import shutil
|
|
5
6
|
import subprocess
|
|
7
|
+
from base64 import b64encode
|
|
6
8
|
from collections.abc import Iterable
|
|
7
9
|
from datetime import datetime, timedelta
|
|
8
10
|
from enum import Enum
|
|
9
11
|
from logging.handlers import RotatingFileHandler
|
|
10
12
|
from pathlib import Path
|
|
13
|
+
from typing import cast
|
|
11
14
|
|
|
12
15
|
from rich.console import Console
|
|
13
16
|
from rich.logging import RichHandler
|
|
@@ -316,3 +319,57 @@ def _trash_path(path: Path) -> None:
|
|
|
316
319
|
)
|
|
317
320
|
except FileNotFoundError:
|
|
318
321
|
path.unlink(missing_ok=True)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# Debug JSON serialization utilities
|
|
325
|
+
_DEBUG_TRUNCATE_PREFIX_CHARS = 96
|
|
326
|
+
|
|
327
|
+
# Keys whose values should be truncated (e.g., signatures, large payloads)
|
|
328
|
+
_TRUNCATE_KEYS = {"thought_signature", "thoughtSignature"}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _truncate_debug_str(value: str, *, prefix_chars: int = _DEBUG_TRUNCATE_PREFIX_CHARS) -> str:
|
|
332
|
+
if len(value) <= prefix_chars:
|
|
333
|
+
return value
|
|
334
|
+
return f"{value[:prefix_chars]}...(truncated,len={len(value)})"
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _sanitize_debug_value(value: object) -> object:
|
|
338
|
+
if isinstance(value, (bytes, bytearray)):
|
|
339
|
+
encoded = b64encode(bytes(value)).decode("ascii")
|
|
340
|
+
return _truncate_debug_str(encoded)
|
|
341
|
+
if isinstance(value, str):
|
|
342
|
+
return value
|
|
343
|
+
if isinstance(value, list):
|
|
344
|
+
return [_sanitize_debug_value(v) for v in cast(list[object], value)]
|
|
345
|
+
if isinstance(value, dict):
|
|
346
|
+
return _sanitize_debug_dict(value) # type: ignore[arg-type]
|
|
347
|
+
return value
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _sanitize_debug_dict(obj: dict[object, object]) -> dict[object, object]:
|
|
351
|
+
sanitized: dict[object, object] = {}
|
|
352
|
+
for k, v in obj.items():
|
|
353
|
+
if k in _TRUNCATE_KEYS:
|
|
354
|
+
if isinstance(v, str):
|
|
355
|
+
sanitized[k] = _truncate_debug_str(v)
|
|
356
|
+
else:
|
|
357
|
+
sanitized[k] = _sanitize_debug_value(v)
|
|
358
|
+
continue
|
|
359
|
+
sanitized[k] = _sanitize_debug_value(v)
|
|
360
|
+
|
|
361
|
+
# Truncate inline image payloads (data field with mime_type indicates image blob)
|
|
362
|
+
if "data" in sanitized and ("mime_type" in sanitized or "mimeType" in sanitized):
|
|
363
|
+
data = sanitized.get("data")
|
|
364
|
+
if isinstance(data, str):
|
|
365
|
+
sanitized["data"] = _truncate_debug_str(data)
|
|
366
|
+
elif isinstance(data, (bytes, bytearray)):
|
|
367
|
+
encoded = b64encode(bytes(data)).decode("ascii")
|
|
368
|
+
sanitized["data"] = _truncate_debug_str(encoded)
|
|
369
|
+
|
|
370
|
+
return sanitized
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def debug_json(value: object) -> str:
|
|
374
|
+
"""Serialize a value to JSON for debug logging, truncating large payloads."""
|
|
375
|
+
return json.dumps(_sanitize_debug_value(value), ensure_ascii=False)
|
|
@@ -3,26 +3,23 @@ import sys
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
4
|
from typing import Literal
|
|
5
5
|
|
|
6
|
-
from prompt_toolkit.styles import Style
|
|
6
|
+
from prompt_toolkit.styles import Style, merge_styles
|
|
7
7
|
|
|
8
8
|
from klaude_code.protocol import commands, events, message, model
|
|
9
9
|
from klaude_code.tui.input.clipboard import copy_to_clipboard
|
|
10
|
-
from klaude_code.tui.terminal.selector import SelectItem, select_one
|
|
10
|
+
from klaude_code.tui.terminal.selector import DEFAULT_PICKER_STYLE, SelectItem, select_one
|
|
11
11
|
|
|
12
12
|
from .command_abc import Agent, CommandABC, CommandResult
|
|
13
13
|
|
|
14
|
-
FORK_SELECT_STYLE =
|
|
14
|
+
FORK_SELECT_STYLE = merge_styles(
|
|
15
15
|
[
|
|
16
|
-
|
|
17
|
-
(
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
("search_none", "noinherit fg:ansired"),
|
|
24
|
-
("question", "bold"),
|
|
25
|
-
("text", ""),
|
|
16
|
+
DEFAULT_PICKER_STYLE,
|
|
17
|
+
Style(
|
|
18
|
+
[
|
|
19
|
+
("separator", "fg:ansibrightblack"),
|
|
20
|
+
("assistant", "fg:ansiblue"),
|
|
21
|
+
]
|
|
22
|
+
),
|
|
26
23
|
]
|
|
27
24
|
)
|
|
28
25
|
|
|
@@ -144,6 +141,7 @@ def _build_select_items(fork_points: list[ForkPoint]) -> list[SelectItem[int]]:
|
|
|
144
141
|
title=title_parts,
|
|
145
142
|
value=fp.history_index,
|
|
146
143
|
search_text=fp.user_message if not is_last else "fork entire conversation",
|
|
144
|
+
selectable=not is_first,
|
|
147
145
|
)
|
|
148
146
|
)
|
|
149
147
|
|
|
@@ -163,6 +161,9 @@ def _select_fork_point_sync(fork_points: list[ForkPoint]) -> int | Literal["canc
|
|
|
163
161
|
|
|
164
162
|
# Default to the last option (fork entire conversation)
|
|
165
163
|
last_value = items[-1].value
|
|
164
|
+
if last_value is None:
|
|
165
|
+
# Should not happen as we populate all items with int values
|
|
166
|
+
return -1
|
|
166
167
|
|
|
167
168
|
# Non-interactive environments default to forking entire conversation
|
|
168
169
|
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
@@ -241,16 +242,6 @@ class ForkSessionCommand(CommandABC):
|
|
|
241
242
|
)
|
|
242
243
|
return CommandResult(events=[event])
|
|
243
244
|
|
|
244
|
-
# First option (empty session) is just for UI display, not a valid fork point
|
|
245
|
-
if selected == fork_points[0].history_index:
|
|
246
|
-
event = events.CommandOutputEvent(
|
|
247
|
-
session_id=agent.session.id,
|
|
248
|
-
command_name=self.name,
|
|
249
|
-
content="(cannot fork to empty session)",
|
|
250
|
-
is_error=True,
|
|
251
|
-
)
|
|
252
|
-
return CommandResult(events=[event])
|
|
253
|
-
|
|
254
245
|
# Perform the fork
|
|
255
246
|
new_session = agent.session.fork(until_index=selected)
|
|
256
247
|
await new_session.wait_for_flush()
|
|
@@ -72,9 +72,7 @@ def select_model_interactive(
|
|
|
72
72
|
return ModelSelectResult(status=ModelSelectStatus.NON_TTY)
|
|
73
73
|
|
|
74
74
|
# Interactive selection
|
|
75
|
-
from
|
|
76
|
-
|
|
77
|
-
from klaude_code.tui.terminal.selector import build_model_select_items, select_one
|
|
75
|
+
from klaude_code.tui.terminal.selector import DEFAULT_PICKER_STYLE, build_model_select_items, select_one
|
|
78
76
|
|
|
79
77
|
names = [m.selector for m in result.filtered_models]
|
|
80
78
|
|
|
@@ -100,20 +98,7 @@ def select_model_interactive(
|
|
|
100
98
|
pointer="→",
|
|
101
99
|
use_search_filter=True,
|
|
102
100
|
initial_value=initial_value,
|
|
103
|
-
style=
|
|
104
|
-
[
|
|
105
|
-
("pointer", "ansigreen"),
|
|
106
|
-
("highlighted", "ansigreen"),
|
|
107
|
-
("msg", ""),
|
|
108
|
-
("meta", "fg:ansibrightblack"),
|
|
109
|
-
("text", "ansibrightblack"),
|
|
110
|
-
("question", "bold"),
|
|
111
|
-
("search_prefix", "ansibrightblack"),
|
|
112
|
-
# search filter colors at the bottom
|
|
113
|
-
("search_success", "noinherit fg:ansigreen"),
|
|
114
|
-
("search_none", "noinherit fg:ansired"),
|
|
115
|
-
]
|
|
116
|
-
),
|
|
101
|
+
style=DEFAULT_PICKER_STYLE,
|
|
117
102
|
)
|
|
118
103
|
if isinstance(selected, str) and selected in names:
|
|
119
104
|
return ModelSelectResult(status=ModelSelectStatus.SELECTED, model=selected)
|
|
@@ -1,28 +1,12 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
|
|
3
|
-
from prompt_toolkit.styles import Style
|
|
4
|
-
|
|
5
3
|
from klaude_code.log import log
|
|
6
4
|
from klaude_code.protocol import commands, events, message, op
|
|
7
5
|
from klaude_code.session.selector import build_session_select_options, format_user_messages_display
|
|
8
|
-
from klaude_code.tui.terminal.selector import SelectItem, select_one
|
|
6
|
+
from klaude_code.tui.terminal.selector import DEFAULT_PICKER_STYLE, SelectItem, select_one
|
|
9
7
|
|
|
10
8
|
from .command_abc import Agent, CommandABC, CommandResult
|
|
11
9
|
|
|
12
|
-
SESSION_SELECT_STYLE = Style(
|
|
13
|
-
[
|
|
14
|
-
("msg", "fg:ansibrightblack"),
|
|
15
|
-
("meta", ""),
|
|
16
|
-
("pointer", "bold fg:ansigreen"),
|
|
17
|
-
("highlighted", "fg:ansigreen"),
|
|
18
|
-
("search_prefix", "fg:ansibrightblack"),
|
|
19
|
-
("search_success", "noinherit fg:ansigreen"),
|
|
20
|
-
("search_none", "noinherit fg:ansired"),
|
|
21
|
-
("question", "bold"),
|
|
22
|
-
("text", ""),
|
|
23
|
-
]
|
|
24
|
-
)
|
|
25
|
-
|
|
26
10
|
|
|
27
11
|
def select_session_sync() -> str | None:
|
|
28
12
|
"""Interactive session selection (sync version for asyncio.to_thread)."""
|
|
@@ -62,7 +46,7 @@ def select_session_sync() -> str | None:
|
|
|
62
46
|
message="Select a session to resume:",
|
|
63
47
|
items=items,
|
|
64
48
|
pointer="→",
|
|
65
|
-
style=
|
|
49
|
+
style=DEFAULT_PICKER_STYLE,
|
|
66
50
|
)
|
|
67
51
|
except KeyboardInterrupt:
|
|
68
52
|
return None
|