klaude-code 1.2.8__py3-none-any.whl → 1.2.10__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 (82) hide show
  1. klaude_code/auth/codex/__init__.py +1 -1
  2. klaude_code/cli/main.py +12 -1
  3. klaude_code/cli/runtime.py +7 -11
  4. klaude_code/command/__init__.py +68 -21
  5. klaude_code/command/clear_cmd.py +6 -2
  6. klaude_code/command/command_abc.py +5 -2
  7. klaude_code/command/diff_cmd.py +5 -2
  8. klaude_code/command/export_cmd.py +7 -4
  9. klaude_code/command/help_cmd.py +6 -2
  10. klaude_code/command/model_cmd.py +5 -2
  11. klaude_code/command/prompt-deslop.md +14 -0
  12. klaude_code/command/prompt_command.py +8 -3
  13. klaude_code/command/refresh_cmd.py +6 -2
  14. klaude_code/command/registry.py +17 -5
  15. klaude_code/command/release_notes_cmd.py +89 -0
  16. klaude_code/command/status_cmd.py +98 -56
  17. klaude_code/command/terminal_setup_cmd.py +7 -4
  18. klaude_code/const/__init__.py +1 -1
  19. klaude_code/core/agent.py +66 -26
  20. klaude_code/core/executor.py +2 -2
  21. klaude_code/core/manager/agent_manager.py +6 -7
  22. klaude_code/core/manager/llm_clients.py +47 -22
  23. klaude_code/core/manager/llm_clients_builder.py +19 -7
  24. klaude_code/core/manager/sub_agent_manager.py +6 -2
  25. klaude_code/core/prompt.py +38 -28
  26. klaude_code/core/reminders.py +4 -7
  27. klaude_code/core/task.py +59 -40
  28. klaude_code/core/tool/__init__.py +2 -0
  29. klaude_code/core/tool/file/_utils.py +30 -0
  30. klaude_code/core/tool/file/apply_patch_tool.py +1 -1
  31. klaude_code/core/tool/file/edit_tool.py +6 -31
  32. klaude_code/core/tool/file/multi_edit_tool.py +7 -32
  33. klaude_code/core/tool/file/read_tool.py +6 -18
  34. klaude_code/core/tool/file/write_tool.py +6 -31
  35. klaude_code/core/tool/memory/__init__.py +5 -0
  36. klaude_code/core/tool/memory/memory_tool.py +2 -2
  37. klaude_code/core/tool/memory/skill_loader.py +2 -1
  38. klaude_code/core/tool/memory/skill_tool.py +13 -0
  39. klaude_code/core/tool/sub_agent_tool.py +2 -1
  40. klaude_code/core/tool/todo/todo_write_tool.py +1 -1
  41. klaude_code/core/tool/todo/update_plan_tool.py +1 -1
  42. klaude_code/core/tool/tool_context.py +21 -4
  43. klaude_code/core/tool/tool_runner.py +5 -8
  44. klaude_code/core/tool/web/mermaid_tool.py +1 -4
  45. klaude_code/core/turn.py +40 -37
  46. klaude_code/llm/__init__.py +2 -12
  47. klaude_code/llm/anthropic/client.py +14 -44
  48. klaude_code/llm/client.py +2 -2
  49. klaude_code/llm/codex/client.py +4 -3
  50. klaude_code/llm/input_common.py +0 -6
  51. klaude_code/llm/openai_compatible/client.py +31 -74
  52. klaude_code/llm/openai_compatible/input.py +6 -4
  53. klaude_code/llm/openai_compatible/stream_processor.py +82 -0
  54. klaude_code/llm/openrouter/client.py +32 -62
  55. klaude_code/llm/openrouter/input.py +4 -27
  56. klaude_code/llm/registry.py +33 -7
  57. klaude_code/llm/responses/client.py +16 -48
  58. klaude_code/llm/responses/input.py +1 -1
  59. klaude_code/llm/usage.py +61 -11
  60. klaude_code/protocol/commands.py +1 -0
  61. klaude_code/protocol/events.py +11 -2
  62. klaude_code/protocol/model.py +147 -24
  63. klaude_code/protocol/op.py +1 -0
  64. klaude_code/protocol/sub_agent.py +5 -1
  65. klaude_code/session/export.py +56 -32
  66. klaude_code/session/session.py +43 -21
  67. klaude_code/session/templates/export_session.html +4 -1
  68. klaude_code/ui/core/input.py +1 -1
  69. klaude_code/ui/modes/repl/__init__.py +1 -5
  70. klaude_code/ui/modes/repl/clipboard.py +5 -5
  71. klaude_code/ui/modes/repl/event_handler.py +153 -54
  72. klaude_code/ui/modes/repl/renderer.py +4 -4
  73. klaude_code/ui/renderers/developer.py +35 -25
  74. klaude_code/ui/renderers/metadata.py +68 -30
  75. klaude_code/ui/renderers/tools.py +53 -87
  76. klaude_code/ui/rich/markdown.py +5 -5
  77. klaude_code/ui/terminal/control.py +2 -2
  78. klaude_code/version.py +3 -3
  79. {klaude_code-1.2.8.dist-info → klaude_code-1.2.10.dist-info}/METADATA +1 -1
  80. {klaude_code-1.2.8.dist-info → klaude_code-1.2.10.dist-info}/RECORD +82 -78
  81. {klaude_code-1.2.8.dist-info → klaude_code-1.2.10.dist-info}/WHEEL +0 -0
  82. {klaude_code-1.2.8.dist-info → klaude_code-1.2.10.dist-info}/entry_points.txt +0 -0
@@ -159,20 +159,35 @@ def _format_cost(cost: float, currency: str = "USD") -> str:
159
159
  return f"{symbol}{cost:.4f}"
160
160
 
161
161
 
162
- def _render_metadata_item(item: model.ResponseMetadataItem) -> str:
163
- # Model Name [@ Provider]
162
+ def _render_single_metadata(
163
+ metadata: model.TaskMetadata,
164
+ *,
165
+ indent: int = 0,
166
+ show_context: bool = True,
167
+ ) -> str:
168
+ """Render a single TaskMetadata block as HTML.
169
+
170
+ Args:
171
+ metadata: The TaskMetadata to render.
172
+ indent: Number of spaces to indent (0 for main, 2 for sub-agents).
173
+ show_context: Whether to show context usage percent.
174
+
175
+ Returns:
176
+ HTML string for this metadata block.
177
+ """
164
178
  parts: list[str] = []
165
179
 
166
- model_parts = [f'<span class="metadata-model">{_escape_html(item.model_name)}</span>']
167
- if item.provider:
168
- provider = _escape_html(item.provider.lower().replace(" ", "-"))
180
+ # Model Name [@ Provider]
181
+ model_parts = [f'<span class="metadata-model">{_escape_html(metadata.model_name)}</span>']
182
+ if metadata.provider:
183
+ provider = _escape_html(metadata.provider.lower().replace(" ", "-"))
169
184
  model_parts.append(f'<span class="metadata-provider">@{provider}</span>')
170
185
 
171
186
  parts.append("".join(model_parts))
172
187
 
173
188
  # Stats
174
- if item.usage:
175
- u = item.usage
189
+ if metadata.usage:
190
+ u = metadata.usage
176
191
  # Input with cost
177
192
  input_stat = f"input: {_format_token_count(u.input_tokens)}"
178
193
  if u.input_cost is not None:
@@ -194,22 +209,39 @@ def _render_metadata_item(item: model.ResponseMetadataItem) -> str:
194
209
 
195
210
  if u.reasoning_tokens > 0:
196
211
  parts.append(f'<span class="metadata-stat">thinking: {_format_token_count(u.reasoning_tokens)}</span>')
197
- if u.context_usage_percent is not None:
212
+ if show_context and u.context_usage_percent is not None:
198
213
  parts.append(f'<span class="metadata-stat">context: {u.context_usage_percent:.1f}%</span>')
199
214
  if u.throughput_tps is not None:
200
215
  parts.append(f'<span class="metadata-stat">tps: {u.throughput_tps:.1f}</span>')
201
216
 
202
- if item.task_duration_s is not None:
203
- parts.append(f'<span class="metadata-stat">time: {item.task_duration_s:.1f}s</span>')
217
+ if metadata.task_duration_s is not None:
218
+ parts.append(f'<span class="metadata-stat">time: {metadata.task_duration_s:.1f}s</span>')
204
219
 
205
220
  # Total cost
206
- if item.usage is not None and item.usage.total_cost is not None:
207
- parts.append(f'<span class="metadata-stat">cost: {_format_cost(item.usage.total_cost, item.usage.currency)}</span>')
221
+ if metadata.usage is not None and metadata.usage.total_cost is not None:
222
+ parts.append(
223
+ f'<span class="metadata-stat">cost: {_format_cost(metadata.usage.total_cost, metadata.usage.currency)}</span>'
224
+ )
208
225
 
209
226
  divider = '<span class="metadata-divider">/</span>'
210
227
  joined_html = divider.join(parts)
211
228
 
212
- return f'<div class="response-metadata"><div class="metadata-line">{joined_html}</div></div>'
229
+ indent_style = f' style="padding-left: {indent}em;"' if indent > 0 else ""
230
+ return f'<div class="metadata-line"{indent_style}>{joined_html}</div>'
231
+
232
+
233
+ def _render_metadata_item(item: model.TaskMetadataItem) -> str:
234
+ """Render TaskMetadataItem including main agent and sub-agents."""
235
+ lines: list[str] = []
236
+
237
+ # Main agent metadata
238
+ lines.append(_render_single_metadata(item.main, indent=0, show_context=True))
239
+
240
+ # Sub-agent metadata with indent
241
+ for sub in item.sub_agent_task_metadata:
242
+ lines.append(_render_single_metadata(sub, indent=1, show_context=False))
243
+
244
+ return f'<div class="response-metadata">{"".join(lines)}</div>'
213
245
 
214
246
 
215
247
  def _render_assistant_message(index: int, content: str, timestamp: datetime) -> str:
@@ -262,7 +294,7 @@ def _try_render_todo_args(arguments: str) -> str | None:
262
294
  return None
263
295
 
264
296
  return f'<div class="todo-list">{"".join(items_html)}</div>'
265
- except Exception:
297
+ except (json.JSONDecodeError, KeyError, TypeError):
266
298
  return None
267
299
 
268
300
 
@@ -336,11 +368,9 @@ def _render_diff_block(diff: str) -> str:
336
368
 
337
369
 
338
370
  def _get_diff_text(ui_extra: model.ToolResultUIExtra | None) -> str | None:
339
- if ui_extra is None:
340
- return None
341
- if ui_extra.type != model.ToolResultUIExtraType.DIFF_TEXT:
342
- return None
343
- return ui_extra.diff_text
371
+ if isinstance(ui_extra, model.DiffTextUIExtra):
372
+ return ui_extra.diff_text
373
+ return None
344
374
 
345
375
 
346
376
  def _get_mermaid_link_html(
@@ -350,14 +380,12 @@ def _get_mermaid_link_html(
350
380
  try:
351
381
  args = json.loads(tool_call.arguments)
352
382
  code = args.get("code", "")
353
- except Exception:
383
+ except (json.JSONDecodeError, TypeError):
354
384
  code = ""
355
385
  else:
356
386
  code = ""
357
387
 
358
- if not code and (
359
- ui_extra is None or ui_extra.type != model.ToolResultUIExtraType.MERMAID_LINK or not ui_extra.mermaid_link
360
- ):
388
+ if not code and not isinstance(ui_extra, model.MermaidLinkUIExtra):
361
389
  return None
362
390
 
363
391
  # Prepare code for rendering and copy
@@ -376,11 +404,7 @@ def _get_mermaid_link_html(
376
404
  f'<button type="button" class="copy-mermaid-btn" data-code="{escaped_code}" title="Copy Mermaid Code">Copy Code</button>'
377
405
  )
378
406
 
379
- link = (
380
- ui_extra.mermaid_link.link
381
- if (ui_extra and ui_extra.type == model.ToolResultUIExtraType.MERMAID_LINK and ui_extra.mermaid_link)
382
- else None
383
- )
407
+ link = ui_extra.link if isinstance(ui_extra, model.MermaidLinkUIExtra) else None
384
408
 
385
409
  if link:
386
410
  link_url = _escape_html(link)
@@ -423,7 +447,7 @@ def _format_tool_call(tool_call: model.ToolCallItem, result: model.ToolResultIte
423
447
  try:
424
448
  parsed = json.loads(tool_call.arguments)
425
449
  args_text = json.dumps(parsed, ensure_ascii=False, indent=2)
426
- except Exception:
450
+ except (json.JSONDecodeError, TypeError):
427
451
  args_text = tool_call.arguments
428
452
 
429
453
  args_html = _escape_html(args_text or "")
@@ -445,7 +469,7 @@ def _format_tool_call(tool_call: model.ToolCallItem, result: model.ToolResultIte
445
469
  parsed_args = json.loads(tool_call.arguments)
446
470
  if parsed_args.get("command") in {"create", "str_replace", "insert"}:
447
471
  force_collapse = True
448
- except Exception:
472
+ except (json.JSONDecodeError, TypeError):
449
473
  pass
450
474
 
451
475
  should_collapse = force_collapse or _should_collapse(args_html)
@@ -482,7 +506,7 @@ def _format_tool_call(tool_call: model.ToolCallItem, result: model.ToolResultIte
482
506
  new_string = args_data.get("new_string", "")
483
507
  if old_string == "" and new_string:
484
508
  diff_text = "\n".join(f"+{line}" for line in new_string.splitlines())
485
- except Exception:
509
+ except (json.JSONDecodeError, TypeError):
486
510
  pass
487
511
 
488
512
  items_to_render: list[str] = []
@@ -544,7 +568,7 @@ def _build_messages_html(
544
568
  elif isinstance(item, model.AssistantMessageItem):
545
569
  assistant_counter += 1
546
570
  blocks.append(_render_assistant_message(assistant_counter, item.content or "", item.created_at))
547
- elif isinstance(item, model.ResponseMetadataItem):
571
+ elif isinstance(item, model.TaskMetadataItem):
548
572
  blocks.append(_render_metadata_item(item))
549
573
  elif isinstance(item, model.DeveloperMessageItem):
550
574
  content = _escape_html(item.content or "")
@@ -5,7 +5,7 @@ from collections.abc import Iterable, Sequence
5
5
  from pathlib import Path
6
6
  from typing import ClassVar
7
7
 
8
- from pydantic import BaseModel, Field
8
+ from pydantic import BaseModel, Field, PrivateAttr
9
9
 
10
10
  from klaude_code.protocol import events, model
11
11
 
@@ -19,8 +19,6 @@ class Session(BaseModel):
19
19
  file_tracker: dict[str, float] = Field(default_factory=dict)
20
20
  # Todo list for the session
21
21
  todos: list[model.TodoItem] = Field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
22
- # Messages count, redundant state for performance optimization to avoid reading entire jsonl file
23
- messages_count: int = Field(default=0)
24
22
  # Model name used for this session
25
23
  # Used in list method SessionMetaBrief
26
24
  model_name: str | None = None
@@ -33,6 +31,27 @@ class Session(BaseModel):
33
31
  need_todo_empty_cooldown_counter: int = Field(exclude=True, default=0)
34
32
  need_todo_not_used_cooldown_counter: int = Field(exclude=True, default=0)
35
33
 
34
+ # Cached messages count (computed property)
35
+ _messages_count_cache: int | None = PrivateAttr(default=None)
36
+
37
+ @property
38
+ def messages_count(self) -> int:
39
+ """Count of user and assistant messages in conversation history.
40
+
41
+ This is a cached property that is invalidated when append_history is called.
42
+ """
43
+ if self._messages_count_cache is None:
44
+ self._messages_count_cache = sum(
45
+ 1
46
+ for it in self.conversation_history
47
+ if isinstance(it, (model.UserMessageItem, model.AssistantMessageItem))
48
+ )
49
+ return self._messages_count_cache
50
+
51
+ def _invalidate_messages_count_cache(self) -> None:
52
+ """Invalidate the cached messages count."""
53
+ self._messages_count_cache = None
54
+
36
55
  # Internal: mapping for (de)serialization of conversation items
37
56
  _TypeMap: ClassVar[dict[str, type[BaseModel]]] = {
38
57
  # Messages
@@ -50,7 +69,7 @@ class Session(BaseModel):
50
69
  "AssistantMessageDelta": model.AssistantMessageDelta,
51
70
  "StartItem": model.StartItem,
52
71
  "StreamErrorItem": model.StreamErrorItem,
53
- "ResponseMetadataItem": model.ResponseMetadataItem,
72
+ "TaskMetadataItem": model.TaskMetadataItem,
54
73
  "InterruptItem": model.InterruptItem,
55
74
  }
56
75
 
@@ -84,7 +103,17 @@ class Session(BaseModel):
84
103
  return self._messages_dir() / f"{prefix}-{self.id}.jsonl"
85
104
 
86
105
  @classmethod
87
- def load(cls, id: str) -> "Session":
106
+ def create(cls, id: str | None = None) -> "Session":
107
+ """Create a new session without checking for existing files."""
108
+ return Session(id=id or uuid.uuid4().hex, work_dir=Path.cwd())
109
+
110
+ @classmethod
111
+ def load(cls, id: str, *, skip_if_missing: bool = False) -> "Session":
112
+ """Load an existing session or create a new one if not found."""
113
+
114
+ if skip_if_missing:
115
+ return Session(id=id, work_dir=Path.cwd())
116
+
88
117
  # Load session metadata
89
118
  sessions_dir = cls._sessions_dir()
90
119
  session_candidates = sorted(
@@ -109,7 +138,6 @@ class Session(BaseModel):
109
138
  loaded_memory = list(raw.get("loaded_memory", []))
110
139
  created_at = float(raw.get("created_at", time.time()))
111
140
  updated_at = float(raw.get("updated_at", created_at))
112
- messages_count = int(raw.get("messages_count", 0))
113
141
  model_name = raw.get("model_name")
114
142
 
115
143
  sess = Session(
@@ -121,7 +149,6 @@ class Session(BaseModel):
121
149
  loaded_memory=loaded_memory,
122
150
  created_at=created_at,
123
151
  updated_at=updated_at,
124
- messages_count=messages_count,
125
152
  model_name=model_name,
126
153
  )
127
154
 
@@ -150,14 +177,11 @@ class Session(BaseModel):
150
177
  item = cls_type(**data)
151
178
  # pyright: ignore[reportAssignmentType]
152
179
  history.append(item) # type: ignore[arg-type]
153
- except Exception:
180
+ except (json.JSONDecodeError, KeyError, TypeError):
154
181
  # Best-effort load; skip malformed lines
155
182
  continue
156
183
  sess.conversation_history = history
157
- # Update messages count based on loaded history (only UserMessageItem and AssistantMessageItem)
158
- sess.messages_count = sum(
159
- 1 for it in history if isinstance(it, (model.UserMessageItem, model.AssistantMessageItem))
160
- )
184
+ # messages_count is now a computed property, no need to set it
161
185
 
162
186
  return sess
163
187
 
@@ -190,10 +214,8 @@ class Session(BaseModel):
190
214
  def append_history(self, items: Sequence[model.ConversationItem]):
191
215
  # Append to in-memory history
192
216
  self.conversation_history.extend(items)
193
- # Update messages count (only UserMessageItem and AssistantMessageItem)
194
- self.messages_count += sum(
195
- 1 for it in items if isinstance(it, (model.UserMessageItem, model.AssistantMessageItem))
196
- )
217
+ # Invalidate messages count cache
218
+ self._invalidate_messages_count_cache()
197
219
 
198
220
  # Incrementally persist to JSONL under messages directory
199
221
  messages_dir = self._messages_dir()
@@ -230,7 +252,7 @@ class Session(BaseModel):
230
252
  if ts > latest_ts:
231
253
  latest_ts = ts
232
254
  latest_id = sid
233
- except Exception:
255
+ except (json.JSONDecodeError, KeyError, TypeError, OSError):
234
256
  continue
235
257
  return latest_id
236
258
 
@@ -295,8 +317,8 @@ class Session(BaseModel):
295
317
  content=ri.content,
296
318
  session_id=self.id,
297
319
  )
298
- case model.ResponseMetadataItem() as mt:
299
- yield events.ResponseMetadataEvent(
320
+ case model.TaskMetadataItem() as mt:
321
+ yield events.TaskMetadataEvent(
300
322
  session_id=self.id,
301
323
  metadata=mt,
302
324
  )
@@ -383,7 +405,7 @@ class Session(BaseModel):
383
405
  text_parts.append(text)
384
406
  return " ".join(text_parts) if text_parts else None
385
407
  return None
386
- except Exception:
408
+ except (json.JSONDecodeError, KeyError, TypeError, OSError):
387
409
  return None
388
410
  return None
389
411
 
@@ -391,7 +413,7 @@ class Session(BaseModel):
391
413
  for p in sessions_dir.glob("*.json"):
392
414
  try:
393
415
  data = json.loads(p.read_text())
394
- except Exception:
416
+ except (json.JSONDecodeError, OSError):
395
417
  # Skip unreadable files
396
418
  continue
397
419
  # Filter out sub-agent sessions
@@ -21,7 +21,7 @@
21
21
  rel="stylesheet"
22
22
  />
23
23
  <link
24
- href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;700&family=IBM+Plex+Sans:wght@400;500;700&display=swap"
24
+ href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,700;1,400;1,500;1,700&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,700;1,400;1,500;1,700&display=swap"
25
25
  rel="stylesheet"
26
26
  />
27
27
  <style>
@@ -411,6 +411,9 @@
411
411
  font-size: var(--font-size-xs);
412
412
  color: var(--text-dim);
413
413
  border-left: 2px solid transparent;
414
+ display: flex;
415
+ flex-direction: column;
416
+ gap: 8px;
414
417
  }
415
418
  .metadata-line {
416
419
  display: flex;
@@ -68,4 +68,4 @@ class InputProviderABC(ABC):
68
68
  UserInputPayload with text and optional images.
69
69
  """
70
70
  raise NotImplementedError
71
- yield UserInputPayload(text="") # pyright: ignore[reportUnreachable]
71
+ yield UserInputPayload(text="")
@@ -22,11 +22,7 @@ def build_repl_status_snapshot(agent: "Agent | None", update_message: str | None
22
22
  tool_calls = 0
23
23
 
24
24
  if agent is not None:
25
- profile = agent.profile
26
- if profile is not None:
27
- model_name = profile.llm_client.model_name or ""
28
- else:
29
- model_name = "N/A"
25
+ model_name = agent.profile.llm_client.model_name or ""
30
26
 
31
27
  history = agent.session.conversation_history
32
28
  for item in history:
@@ -40,19 +40,19 @@ class ClipboardCaptureState:
40
40
  """Capture image from clipboard, save to disk, and return a tag like [Image #N]."""
41
41
  try:
42
42
  clipboard_data = ImageGrab.grabclipboard()
43
- except Exception:
43
+ except OSError:
44
44
  return None
45
45
  if not isinstance(clipboard_data, Image.Image):
46
46
  return None
47
47
  try:
48
48
  self._images_dir.mkdir(parents=True, exist_ok=True)
49
- except Exception:
49
+ except OSError:
50
50
  return None
51
51
  filename = f"clipboard_{uuid.uuid4().hex[:8]}.png"
52
52
  path = self._images_dir / filename
53
53
  try:
54
54
  clipboard_data.save(path, "PNG")
55
- except Exception:
55
+ except OSError:
56
56
  return None
57
57
  tag = f"[Image #{self._counter}]"
58
58
  self._counter += 1
@@ -123,7 +123,7 @@ def _encode_image_file(file_path: str) -> ImageURLPart | None:
123
123
  # Clipboard images are always saved as PNG
124
124
  data_url = f"data:image/png;base64,{encoded}"
125
125
  return ImageURLPart(image_url=ImageURLPart.ImageURL(url=data_url, id=None))
126
- except Exception:
126
+ except OSError:
127
127
  return None
128
128
 
129
129
 
@@ -148,5 +148,5 @@ def copy_to_clipboard(text: str) -> None:
148
148
  input=text.encode("utf-8"),
149
149
  check=True,
150
150
  )
151
- except Exception:
151
+ except (OSError, subprocess.SubprocessError):
152
152
  pass