klaude-code 2.9.0__py3-none-any.whl → 2.9.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. klaude_code/auth/antigravity/oauth.py +33 -29
  2. klaude_code/auth/claude/oauth.py +34 -49
  3. klaude_code/config/assets/builtin_config.yaml +17 -0
  4. klaude_code/core/agent_profile.py +2 -5
  5. klaude_code/core/task.py +1 -1
  6. klaude_code/core/tool/file/read_tool.py +13 -2
  7. klaude_code/core/tool/shell/bash_tool.py +1 -1
  8. klaude_code/llm/bedrock_anthropic/__init__.py +3 -0
  9. klaude_code/llm/input_common.py +18 -0
  10. klaude_code/llm/{codex → openai_codex}/__init__.py +1 -1
  11. klaude_code/llm/{codex → openai_codex}/client.py +3 -3
  12. klaude_code/llm/openai_compatible/client.py +3 -1
  13. klaude_code/llm/openai_compatible/stream.py +19 -9
  14. klaude_code/llm/{responses → openai_responses}/client.py +1 -1
  15. klaude_code/llm/registry.py +3 -3
  16. klaude_code/llm/stream_parts.py +3 -1
  17. klaude_code/llm/usage.py +1 -1
  18. klaude_code/protocol/events.py +0 -1
  19. klaude_code/protocol/message.py +1 -0
  20. klaude_code/protocol/model.py +14 -1
  21. klaude_code/session/session.py +22 -1
  22. klaude_code/tui/components/bash_syntax.py +4 -0
  23. klaude_code/tui/components/diffs.py +3 -2
  24. klaude_code/tui/components/metadata.py +0 -3
  25. klaude_code/tui/components/rich/markdown.py +120 -33
  26. klaude_code/tui/components/rich/status.py +2 -2
  27. klaude_code/tui/components/rich/theme.py +9 -6
  28. klaude_code/tui/components/tools.py +22 -0
  29. klaude_code/tui/components/user_input.py +2 -0
  30. klaude_code/tui/machine.py +25 -47
  31. klaude_code/tui/renderer.py +37 -13
  32. klaude_code/tui/terminal/image.py +24 -3
  33. {klaude_code-2.9.0.dist-info → klaude_code-2.9.1.dist-info}/METADATA +1 -1
  34. {klaude_code-2.9.0.dist-info → klaude_code-2.9.1.dist-info}/RECORD +40 -40
  35. klaude_code/llm/bedrock/__init__.py +0 -3
  36. /klaude_code/llm/{bedrock → bedrock_anthropic}/client.py +0 -0
  37. /klaude_code/llm/{codex → openai_codex}/prompt_sync.py +0 -0
  38. /klaude_code/llm/{responses → openai_responses}/__init__.py +0 -0
  39. /klaude_code/llm/{responses → openai_responses}/input.py +0 -0
  40. {klaude_code-2.9.0.dist-info → klaude_code-2.9.1.dist-info}/WHEEL +0 -0
  41. {klaude_code-2.9.0.dist-info → klaude_code-2.9.1.dist-info}/entry_points.txt +0 -0
@@ -258,42 +258,46 @@ class AntigravityOAuth:
258
258
  )
259
259
 
260
260
  def refresh(self) -> AntigravityAuthState:
261
- """Refresh the access token using refresh token."""
262
- state = self.token_manager.get_state()
263
- if state is None:
264
- raise AntigravityNotLoggedInError("Not logged in to Antigravity. Run 'klaude login antigravity' first.")
261
+ """Refresh the access token using refresh token with file locking.
265
262
 
266
- data = {
267
- "client_id": CLIENT_ID,
268
- "client_secret": CLIENT_SECRET,
269
- "refresh_token": state.refresh_token,
270
- "grant_type": "refresh_token",
271
- }
263
+ Uses file locking to prevent multiple instances from refreshing simultaneously.
264
+ If another instance has already refreshed, returns the updated state.
265
+ """
272
266
 
273
- with httpx.Client() as client:
274
- response = client.post(TOKEN_URL, data=data, timeout=30)
267
+ def do_refresh(current_state: AntigravityAuthState) -> AntigravityAuthState:
268
+ data = {
269
+ "client_id": CLIENT_ID,
270
+ "client_secret": CLIENT_SECRET,
271
+ "refresh_token": current_state.refresh_token,
272
+ "grant_type": "refresh_token",
273
+ }
275
274
 
276
- if response.status_code != 200:
277
- raise AntigravityTokenExpiredError(f"Token refresh failed: {response.text}")
275
+ with httpx.Client() as client:
276
+ response = client.post(TOKEN_URL, data=data, timeout=30)
278
277
 
279
- tokens = response.json()
280
- access_token = tokens["access_token"]
281
- refresh_token = tokens.get("refresh_token", state.refresh_token)
282
- expires_in = tokens.get("expires_in", 3600)
278
+ if response.status_code != 200:
279
+ raise AntigravityTokenExpiredError(f"Token refresh failed: {response.text}")
283
280
 
284
- # Calculate expiry time with 5 minute buffer
285
- expires_at = int(time.time()) + expires_in - 300
281
+ tokens = response.json()
282
+ access_token = tokens["access_token"]
283
+ refresh_token = tokens.get("refresh_token", current_state.refresh_token)
284
+ expires_in = tokens.get("expires_in", 3600)
286
285
 
287
- new_state = AntigravityAuthState(
288
- access_token=access_token,
289
- refresh_token=refresh_token,
290
- expires_at=expires_at,
291
- project_id=state.project_id,
292
- email=state.email,
293
- )
286
+ # Calculate expiry time with 5 minute buffer
287
+ expires_at = int(time.time()) + expires_in - 300
288
+
289
+ return AntigravityAuthState(
290
+ access_token=access_token,
291
+ refresh_token=refresh_token,
292
+ expires_at=expires_at,
293
+ project_id=current_state.project_id,
294
+ email=current_state.email,
295
+ )
294
296
 
295
- self.token_manager.save(new_state)
296
- return new_state
297
+ try:
298
+ return self.token_manager.refresh_with_lock(do_refresh)
299
+ except ValueError as e:
300
+ raise AntigravityNotLoggedInError(str(e)) from e
297
301
 
298
302
  def ensure_valid_token(self) -> tuple[str, str]:
299
303
  """Ensure we have a valid access token, refreshing if needed.
@@ -125,60 +125,45 @@ class ClaudeOAuth:
125
125
  expires_at=int(time.time()) + int(expires_in),
126
126
  )
127
127
 
128
- def _do_refresh_request(self, refresh_token: str) -> httpx.Response:
129
- """Send token refresh request to OAuth server."""
130
- payload = {
131
- "grant_type": "refresh_token",
132
- "client_id": CLIENT_ID,
133
- "refresh_token": refresh_token,
134
- }
135
- with httpx.Client() as client:
136
- return client.post(
137
- TOKEN_URL,
138
- json=payload,
139
- headers={"Content-Type": "application/json"},
140
- )
141
-
142
128
  def refresh(self) -> ClaudeAuthState:
143
- """Refresh the access token using refresh token.
129
+ """Refresh the access token using refresh token with file locking.
144
130
 
145
- Handles concurrent refresh race conditions by retrying with freshly loaded token
146
- if the first attempt fails with invalid_grant error.
131
+ Uses file locking to prevent multiple instances from refreshing simultaneously.
132
+ If another instance has already refreshed, returns the updated state.
147
133
  """
148
- state = self.token_manager.get_state()
149
- if state is None:
150
- raise ClaudeNotLoggedInError("Not logged in to Claude. Run 'klaude login claude' first.")
151
134
 
152
- response = self._do_refresh_request(state.refresh_token)
153
-
154
- # Handle race condition: another process may have refreshed the token already
155
- if response.status_code != 200 and "invalid_grant" in response.text:
156
- # Reload token from file (another process may have updated it)
157
- self.token_manager.clear_cached_state()
158
- fresh_state = self.token_manager.load()
159
- if fresh_state and fresh_state.refresh_token != state.refresh_token:
160
- # Token was updated by another process
161
- if not fresh_state.is_expired():
162
- # New token is still valid, use it directly
163
- return fresh_state
164
- # New token expired, try refreshing with the new refresh_token
165
- response = self._do_refresh_request(fresh_state.refresh_token)
166
-
167
- if response.status_code != 200:
168
- raise ClaudeAuthError(f"Token refresh failed: {response.text}")
169
-
170
- tokens = response.json()
171
- access_token = tokens["access_token"]
172
- refresh_token = tokens.get("refresh_token", state.refresh_token)
173
- expires_in = tokens.get("expires_in", 3600)
135
+ def do_refresh(current_state: ClaudeAuthState) -> ClaudeAuthState:
136
+ payload = {
137
+ "grant_type": "refresh_token",
138
+ "client_id": CLIENT_ID,
139
+ "refresh_token": current_state.refresh_token,
140
+ }
141
+
142
+ with httpx.Client() as client:
143
+ response = client.post(
144
+ TOKEN_URL,
145
+ json=payload,
146
+ headers={"Content-Type": "application/json"},
147
+ )
148
+
149
+ if response.status_code != 200:
150
+ raise ClaudeAuthError(f"Token refresh failed: {response.text}")
151
+
152
+ tokens = response.json()
153
+ access_token = tokens["access_token"]
154
+ refresh_token = tokens.get("refresh_token", current_state.refresh_token)
155
+ expires_in = tokens.get("expires_in", 3600)
156
+
157
+ return ClaudeAuthState(
158
+ access_token=access_token,
159
+ refresh_token=refresh_token,
160
+ expires_at=int(time.time()) + int(expires_in),
161
+ )
174
162
 
175
- new_state = ClaudeAuthState(
176
- access_token=access_token,
177
- refresh_token=refresh_token,
178
- expires_at=int(time.time()) + int(expires_in),
179
- )
180
- self.token_manager.save(new_state)
181
- return new_state
163
+ try:
164
+ return self.token_manager.refresh_with_lock(do_refresh)
165
+ except ValueError as e:
166
+ raise ClaudeNotLoggedInError(str(e)) from e
182
167
 
183
168
  def ensure_valid_token(self) -> str:
184
169
  """Ensure we have a valid access token, refreshing if needed."""
@@ -148,6 +148,8 @@ provider_list:
148
148
  modalities:
149
149
  - image
150
150
  - text
151
+ image_config:
152
+ image_size: "4K"
151
153
  cost: {input: 2, output: 12, cache_read: 0.2, image: 120}
152
154
 
153
155
  - model_name: nano-banana
@@ -221,6 +223,8 @@ provider_list:
221
223
  modalities:
222
224
  - image
223
225
  - text
226
+ image_config:
227
+ image_size: "4K"
224
228
  cost: {input: 2, output: 12, cache_read: 0.2, image: 120}
225
229
 
226
230
  - model_name: nano-banana
@@ -275,6 +279,19 @@ provider_list:
275
279
  cost: {input: 4, output: 16, cache_read: 1, currency: CNY}
276
280
 
277
281
 
282
+ - provider_name: cerebras
283
+ protocol: openai
284
+ api_key: ${CEREBRAS_API_KEY}
285
+ base_url: https://api.cerebras.ai/v1
286
+ model_list:
287
+
288
+ - model_name: glm
289
+ model_id: zai-glm-4.7
290
+ context_limit: 131072
291
+ max_tokens: 12800
292
+ cost: {input: 2.25, output: 2.75}
293
+
294
+
278
295
  - provider_name: claude-max
279
296
  protocol: claude_oauth
280
297
  disabled: true
@@ -132,7 +132,7 @@ def load_system_prompt(
132
132
 
133
133
  # For codex_oauth protocol, use dynamic prompts from GitHub (no additions).
134
134
  if protocol == llm_param.LLMClientProtocol.CODEX_OAUTH:
135
- from klaude_code.llm.codex.prompt_sync import get_codex_instructions
135
+ from klaude_code.llm.openai_codex.prompt_sync import get_codex_instructions
136
136
 
137
137
  return get_codex_instructions(model_name)
138
138
 
@@ -176,8 +176,6 @@ def load_agent_tools(
176
176
  # Main agent tools
177
177
  if "gpt-5" in model_name:
178
178
  tool_names: list[str] = [tools.BASH, tools.READ, tools.APPLY_PATCH, tools.UPDATE_PLAN]
179
- elif "gemini-3" in model_name:
180
- tool_names = [tools.BASH, tools.READ, tools.EDIT, tools.WRITE]
181
179
  else:
182
180
  tool_names = [tools.BASH, tools.READ, tools.EDIT, tools.WRITE, tools.TODO_WRITE]
183
181
 
@@ -189,8 +187,7 @@ def load_agent_tools(
189
187
  else:
190
188
  tool_names.append(tools.IMAGE_GEN)
191
189
 
192
- tool_names.extend([tools.MERMAID])
193
- # tool_names.extend([tools.MEMORY])
190
+ tool_names.append(tools.MERMAID)
194
191
  return get_tool_schemas(tool_names)
195
192
 
196
193
 
klaude_code/core/task.py CHANGED
@@ -210,7 +210,7 @@ class TaskExecutor:
210
210
  accumulated = self._metadata_accumulator.get_partial_item(task_duration_s)
211
211
  if accumulated is not None:
212
212
  session_id = self._context.session_ctx.session_id
213
- ui_events.append(events.TaskMetadataEvent(metadata=accumulated, session_id=session_id, cancelled=True))
213
+ ui_events.append(events.TaskMetadataEvent(metadata=accumulated, session_id=session_id))
214
214
  self._context.session_ctx.append_history([accumulated])
215
215
 
216
216
  return ui_events
@@ -22,7 +22,7 @@ from klaude_code.core.tool.file._utils import file_exists, is_directory
22
22
  from klaude_code.core.tool.tool_abc import ToolABC, load_desc
23
23
  from klaude_code.core.tool.tool_registry import register
24
24
  from klaude_code.protocol import llm_param, message, model, tools
25
- from klaude_code.protocol.model import ImageUIExtra
25
+ from klaude_code.protocol.model import ImageUIExtra, ReadPreviewLine, ReadPreviewUIExtra
26
26
 
27
27
  _IMAGE_MIME_TYPES: dict[str, str] = {
28
28
  ".png": "image/png",
@@ -346,4 +346,15 @@ class ReadTool(ToolABC):
346
346
  read_result_str = "\n".join(lines_out)
347
347
  _track_file_access(context.file_tracker, file_path, content_sha256=read_result.content_sha256)
348
348
 
349
- return message.ToolResultMessage(status="success", output_text=read_result_str)
349
+ # When offset > 1, show a preview of the first 5 lines in UI
350
+ ui_extra = None
351
+ if args.offset is not None and args.offset > 1:
352
+ preview_count = 5
353
+ preview_lines = [
354
+ ReadPreviewLine(line_no=line_no, content=content)
355
+ for line_no, content in read_result.selected_lines[:preview_count]
356
+ ]
357
+ remaining = len(read_result.selected_lines) - len(preview_lines)
358
+ ui_extra = ReadPreviewUIExtra(lines=preview_lines, remaining_lines=remaining)
359
+
360
+ return message.ToolResultMessage(status="success", output_text=read_result_str, ui_extra=ui_extra)
@@ -342,7 +342,7 @@ class BashTool(ToolABC):
342
342
  if not combined:
343
343
  combined = f"Command exited with code {rc}"
344
344
  return message.ToolResultMessage(
345
- status="error",
345
+ status="success",
346
346
  # Preserve leading whitespace; only trim trailing newlines.
347
347
  output_text=combined.rstrip("\n"),
348
348
  )
@@ -0,0 +1,3 @@
1
+ from klaude_code.llm.bedrock_anthropic.client import BedrockClient
2
+
3
+ __all__ = ["BedrockClient"]
@@ -149,6 +149,14 @@ def build_assistant_common_fields(
149
149
  }
150
150
  for tc in tool_calls
151
151
  ]
152
+
153
+ thinking_parts = [part for part in msg.parts if isinstance(part, message.ThinkingTextPart)]
154
+ if thinking_parts:
155
+ thinking_text = "".join(part.text for part in thinking_parts)
156
+ reasoning_field = next((p.reasoning_field for p in thinking_parts if p.reasoning_field), None)
157
+ if thinking_text and reasoning_field:
158
+ result[reasoning_field] = thinking_text
159
+
152
160
  return result
153
161
 
154
162
 
@@ -185,4 +193,14 @@ def apply_config_defaults(param: "LLMCallParameter", config: "LLMConfigParameter
185
193
  param.verbosity = config.verbosity
186
194
  if param.thinking is None:
187
195
  param.thinking = config.thinking
196
+ if param.modalities is None:
197
+ param.modalities = config.modalities
198
+ if param.image_config is None:
199
+ param.image_config = config.image_config
200
+ elif config.image_config is not None:
201
+ # Merge field-level: param overrides config defaults
202
+ if param.image_config.aspect_ratio is None:
203
+ param.image_config.aspect_ratio = config.image_config.aspect_ratio
204
+ if param.image_config.image_size is None:
205
+ param.image_config.image_size = config.image_config.image_size
188
206
  return param
@@ -1,5 +1,5 @@
1
1
  """Codex LLM client using ChatGPT subscription."""
2
2
 
3
- from klaude_code.llm.codex.client import CodexClient
3
+ from klaude_code.llm.openai_codex.client import CodexClient
4
4
 
5
5
  __all__ = ["CodexClient"]
@@ -20,9 +20,9 @@ from klaude_code.const import (
20
20
  )
21
21
  from klaude_code.llm.client import LLMClientABC, LLMStreamABC
22
22
  from klaude_code.llm.input_common import apply_config_defaults
23
+ from klaude_code.llm.openai_responses.client import ResponsesLLMStream
24
+ from klaude_code.llm.openai_responses.input import convert_history_to_input, convert_tool_schema
23
25
  from klaude_code.llm.registry import register
24
- from klaude_code.llm.responses.client import ResponsesLLMStream
25
- from klaude_code.llm.responses.input import convert_history_to_input, convert_tool_schema
26
26
  from klaude_code.llm.usage import MetadataTracker, error_llm_stream
27
27
  from klaude_code.log import DebugType, log_debug
28
28
  from klaude_code.protocol import llm_param
@@ -164,7 +164,7 @@ def _is_invalid_instruction_error(e: Exception) -> bool:
164
164
 
165
165
  def _invalidate_prompt_cache_for_model(model_id: str) -> None:
166
166
  """Invalidate the cached prompt for a model to force refresh."""
167
- from klaude_code.llm.codex.prompt_sync import invalidate_cache
167
+ from klaude_code.llm.openai_codex.prompt_sync import invalidate_cache
168
168
 
169
169
  log_debug(
170
170
  f"Invalidating prompt cache for model {model_id} due to invalid instruction error",
@@ -39,9 +39,11 @@ def build_payload(param: llm_param.LLMCallParameter) -> tuple[CompletionCreatePa
39
39
  "max_tokens": param.max_tokens,
40
40
  "tools": tools,
41
41
  "reasoning_effort": param.thinking.reasoning_effort if param.thinking else None,
42
- "verbosity": param.verbosity,
43
42
  }
44
43
 
44
+ if param.verbosity:
45
+ payload["verbosity"] = param.verbosity
46
+
45
47
  return payload, extra_body
46
48
 
47
49
 
@@ -76,9 +76,11 @@ class StreamStateManager:
76
76
  """Set the response ID once received from the stream."""
77
77
  self.response_id = response_id
78
78
 
79
- def append_thinking_text(self, text: str) -> None:
79
+ def append_thinking_text(self, text: str, *, reasoning_field: str | None = None) -> None:
80
80
  """Append thinking text, merging with the previous ThinkingTextPart when possible."""
81
- append_thinking_text_part(self.assistant_parts, text, model_id=self.param_model)
81
+ append_thinking_text_part(
82
+ self.assistant_parts, text, model_id=self.param_model, reasoning_field=reasoning_field
83
+ )
82
84
 
83
85
  def append_text(self, text: str) -> None:
84
86
  """Append assistant text, merging with the previous TextPart when possible."""
@@ -150,6 +152,7 @@ class ReasoningDeltaResult:
150
152
 
151
153
  handled: bool
152
154
  outputs: list[str | message.Part]
155
+ reasoning_field: str | None = None # Original field name: reasoning_content, reasoning, reasoning_text
153
156
 
154
157
 
155
158
  class ReasoningHandlerABC(ABC):
@@ -168,8 +171,11 @@ class ReasoningHandlerABC(ABC):
168
171
  """Flush buffered reasoning content (usually at stage transition/finalize)."""
169
172
 
170
173
 
174
+ REASONING_FIELDS = ("reasoning_content", "reasoning", "reasoning_text")
175
+
176
+
171
177
  class DefaultReasoningHandler(ReasoningHandlerABC):
172
- """Handles OpenAI-compatible reasoning fields (reasoning_content / reasoning)."""
178
+ """Handles OpenAI-compatible reasoning fields (reasoning_content / reasoning / reasoning_text)."""
173
179
 
174
180
  def __init__(
175
181
  self,
@@ -179,16 +185,20 @@ class DefaultReasoningHandler(ReasoningHandlerABC):
179
185
  ) -> None:
180
186
  self._param_model = param_model
181
187
  self._response_id = response_id
188
+ self._reasoning_field: str | None = None
182
189
 
183
190
  def set_response_id(self, response_id: str | None) -> None:
184
191
  self._response_id = response_id
185
192
 
186
193
  def on_delta(self, delta: object) -> ReasoningDeltaResult:
187
- reasoning_content = getattr(delta, "reasoning_content", None) or getattr(delta, "reasoning", None) or ""
188
- if not reasoning_content:
189
- return ReasoningDeltaResult(handled=False, outputs=[])
190
- text = str(reasoning_content)
191
- return ReasoningDeltaResult(handled=True, outputs=[text])
194
+ for field_name in REASONING_FIELDS:
195
+ content = getattr(delta, field_name, None)
196
+ if content:
197
+ if self._reasoning_field is None:
198
+ self._reasoning_field = field_name
199
+ text = str(content)
200
+ return ReasoningDeltaResult(handled=True, outputs=[text], reasoning_field=self._reasoning_field)
201
+ return ReasoningDeltaResult(handled=False, outputs=[])
192
202
 
193
203
  def flush(self) -> list[message.Part]:
194
204
  return []
@@ -282,7 +292,7 @@ async def parse_chat_completions_stream(
282
292
  if not output:
283
293
  continue
284
294
  metadata_tracker.record_token()
285
- state.append_thinking_text(output)
295
+ state.append_thinking_text(output, reasoning_field=reasoning_result.reasoning_field)
286
296
  yield message.ThinkingTextDelta(content=output, response_id=state.response_id)
287
297
  else:
288
298
  state.assistant_parts.append(output)
@@ -11,8 +11,8 @@ 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.openai_responses.input import convert_history_to_input, convert_tool_schema
14
15
  from klaude_code.llm.registry import register
15
- from klaude_code.llm.responses.input import convert_history_to_input, convert_tool_schema
16
16
  from klaude_code.llm.stream_parts import (
17
17
  append_text_part,
18
18
  append_thinking_text_part,
@@ -15,11 +15,11 @@ _REGISTRY: dict[llm_param.LLMClientProtocol, type["LLMClientABC"]] = {}
15
15
  _PROTOCOL_MODULES: dict[llm_param.LLMClientProtocol, str] = {
16
16
  llm_param.LLMClientProtocol.ANTHROPIC: "klaude_code.llm.anthropic",
17
17
  llm_param.LLMClientProtocol.CLAUDE_OAUTH: "klaude_code.llm.claude",
18
- llm_param.LLMClientProtocol.BEDROCK: "klaude_code.llm.bedrock",
19
- llm_param.LLMClientProtocol.CODEX_OAUTH: "klaude_code.llm.codex",
18
+ llm_param.LLMClientProtocol.BEDROCK: "klaude_code.llm.bedrock_anthropic",
19
+ llm_param.LLMClientProtocol.CODEX_OAUTH: "klaude_code.llm.openai_codex",
20
20
  llm_param.LLMClientProtocol.OPENAI: "klaude_code.llm.openai_compatible",
21
21
  llm_param.LLMClientProtocol.OPENROUTER: "klaude_code.llm.openrouter",
22
- llm_param.LLMClientProtocol.RESPONSES: "klaude_code.llm.responses",
22
+ llm_param.LLMClientProtocol.RESPONSES: "klaude_code.llm.openai_responses",
23
23
  llm_param.LLMClientProtocol.GOOGLE: "klaude_code.llm.google",
24
24
  llm_param.LLMClientProtocol.ANTIGRAVITY: "klaude_code.llm.antigravity",
25
25
  }
@@ -24,6 +24,7 @@ def append_thinking_text_part(
24
24
  text: str,
25
25
  *,
26
26
  model_id: str,
27
+ reasoning_field: str | None = None,
27
28
  force_new: bool = False,
28
29
  ) -> int | None:
29
30
  if not text:
@@ -35,10 +36,11 @@ def append_thinking_text_part(
35
36
  parts[-1] = message.ThinkingTextPart(
36
37
  text=last.text + text,
37
38
  model_id=model_id,
39
+ reasoning_field=reasoning_field or last.reasoning_field,
38
40
  )
39
41
  return len(parts) - 1
40
42
 
41
- parts.append(message.ThinkingTextPart(text=text, model_id=model_id))
43
+ parts.append(message.ThinkingTextPart(text=text, model_id=model_id, reasoning_field=reasoning_field))
42
44
  return len(parts) - 1
43
45
 
44
46
 
klaude_code/llm/usage.py CHANGED
@@ -28,7 +28,7 @@ def calculate_cost(usage: model.Usage, cost_config: llm_param.Cost | None) -> No
28
28
  usage.output_cost = (usage.output_tokens / 1_000_000) * cost_config.output
29
29
 
30
30
  # Cache read cost
31
- usage.cache_read_cost = (usage.cached_tokens / 1_000_000) * cost_config.cache_read
31
+ usage.cache_read_cost = (usage.cached_tokens / 1_000_000) * (cost_config.cache_read or cost_config.input)
32
32
 
33
33
  # Image generation cost
34
34
  usage.image_cost = (usage.image_tokens / 1_000_000) * cost_config.image
@@ -119,7 +119,6 @@ class UsageEvent(ResponseEvent):
119
119
 
120
120
  class TaskMetadataEvent(Event):
121
121
  metadata: model.TaskMetadataItem
122
- cancelled: bool = False
123
122
 
124
123
 
125
124
  class ThinkingStartEvent(ResponseEvent):
@@ -112,6 +112,7 @@ class ThinkingTextPart(BaseModel):
112
112
  id: str | None = None
113
113
  text: str
114
114
  model_id: str | None = None
115
+ reasoning_field: str | None = None # Original field name: reasoning_content, reasoning, reasoning_text
115
116
 
116
117
 
117
118
  class ThinkingSignaturePart(BaseModel):
@@ -228,6 +228,17 @@ class MarkdownDocUIExtra(BaseModel):
228
228
  content: str
229
229
 
230
230
 
231
+ class ReadPreviewLine(BaseModel):
232
+ line_no: int
233
+ content: str
234
+
235
+
236
+ class ReadPreviewUIExtra(BaseModel):
237
+ type: Literal["read_preview"] = "read_preview"
238
+ lines: list[ReadPreviewLine]
239
+ remaining_lines: int # lines not shown in preview
240
+
241
+
231
242
  class SessionStatusUIExtra(BaseModel):
232
243
  type: Literal["session_status"] = "session_status"
233
244
  usage: "Usage"
@@ -243,6 +254,7 @@ MultiUIExtraItem = (
243
254
  | ImageUIExtra
244
255
  | MarkdownDocUIExtra
245
256
  | SessionStatusUIExtra
257
+ | ReadPreviewUIExtra
246
258
  )
247
259
 
248
260
 
@@ -265,7 +277,8 @@ ToolResultUIExtra = Annotated[
265
277
  | ImageUIExtra
266
278
  | MarkdownDocUIExtra
267
279
  | SessionStatusUIExtra
268
- | MultiUIExtra,
280
+ | MultiUIExtra
281
+ | ReadPreviewUIExtra,
269
282
  Field(discriminator="type"),
270
283
  ]
271
284
 
@@ -316,10 +316,15 @@ class Session(BaseModel):
316
316
  prev_item: message.HistoryEvent | None = None
317
317
  last_assistant_content: str = ""
318
318
  report_back_result: str | None = None
319
+ pending_tool_calls: dict[str, events.ToolCallEvent] = {}
319
320
  history = self.conversation_history
320
321
  history_len = len(history)
321
322
  yield events.TaskStartEvent(session_id=self.id, sub_agent_state=self.sub_agent_state)
322
323
  for idx, it in enumerate(history):
324
+ # Flush pending tool calls if current item won't consume them
325
+ if pending_tool_calls and not isinstance(it, message.ToolResultMessage):
326
+ yield from pending_tool_calls.values()
327
+ pending_tool_calls.clear()
323
328
  if self.need_turn_start(prev_item, it):
324
329
  yield events.TurnStartEvent(session_id=self.id)
325
330
  match it:
@@ -331,6 +336,7 @@ class Session(BaseModel):
331
336
  # Reconstruct streaming boundaries from saved parts.
332
337
  # This allows replay to reuse the same TUI state machine as live events.
333
338
  thinking_open = False
339
+ thinking_had_content = False
334
340
  assistant_open = False
335
341
 
336
342
  for part in am.parts:
@@ -342,15 +348,23 @@ class Session(BaseModel):
342
348
  thinking_open = True
343
349
  yield events.ThinkingStartEvent(response_id=am.response_id, session_id=self.id)
344
350
  if part.text:
351
+ if thinking_had_content:
352
+ yield events.ThinkingDeltaEvent(
353
+ content=" \n \n",
354
+ response_id=am.response_id,
355
+ session_id=self.id,
356
+ )
345
357
  yield events.ThinkingDeltaEvent(
346
358
  content=part.text,
347
359
  response_id=am.response_id,
348
360
  session_id=self.id,
349
361
  )
362
+ thinking_had_content = True
350
363
  continue
351
364
 
352
365
  if thinking_open:
353
366
  thinking_open = False
367
+ thinking_had_content = False
354
368
  yield events.ThinkingEndEvent(response_id=am.response_id, session_id=self.id)
355
369
 
356
370
  if isinstance(part, message.TextPart):
@@ -380,7 +394,7 @@ class Session(BaseModel):
380
394
  continue
381
395
  if part.tool_name == tools.REPORT_BACK:
382
396
  report_back_result = part.arguments_json
383
- yield events.ToolCallEvent(
397
+ pending_tool_calls[part.call_id] = events.ToolCallEvent(
384
398
  tool_call_id=part.call_id,
385
399
  tool_name=part.tool_name,
386
400
  arguments=part.arguments_json,
@@ -390,6 +404,8 @@ class Session(BaseModel):
390
404
  if am.stop_reason == "aborted":
391
405
  yield events.InterruptEvent(session_id=self.id)
392
406
  case message.ToolResultMessage() as tr:
407
+ if tr.call_id in pending_tool_calls:
408
+ yield pending_tool_calls.pop(tr.call_id)
393
409
  status = "success" if tr.status == "success" else "error"
394
410
  # Check if this is the last tool result in the current turn
395
411
  next_item = history[idx + 1] if idx + 1 < history_len else None
@@ -437,6 +453,11 @@ class Session(BaseModel):
437
453
  pass
438
454
  prev_item = it
439
455
 
456
+ # Flush any remaining pending tool calls (e.g., from aborted or incomplete sessions)
457
+ if pending_tool_calls:
458
+ yield from pending_tool_calls.values()
459
+ pending_tool_calls.clear()
460
+
440
461
  has_structured_output = report_back_result is not None
441
462
  task_result = report_back_result if has_structured_output else last_assistant_content
442
463
 
@@ -187,6 +187,10 @@ def highlight_bash_command(command: str) -> Text:
187
187
  expect_subcommand = False
188
188
  elif token_type in (Token.Text.Whitespace,):
189
189
  result.append(token_value)
190
+ # Newline starts a new command context (like ; or &&)
191
+ if "\n" in token_value:
192
+ expect_command = True
193
+ expect_subcommand = False
190
194
  elif token_type == Token.Name.Builtin:
191
195
  # Built-in commands are always commands
192
196
  result.append(token_value, style=ThemeKey.BASH_COMMAND)