klaude-code 1.8.0__py3-none-any.whl → 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. klaude_code/auth/base.py +97 -0
  2. klaude_code/auth/claude/__init__.py +6 -0
  3. klaude_code/auth/claude/exceptions.py +9 -0
  4. klaude_code/auth/claude/oauth.py +172 -0
  5. klaude_code/auth/claude/token_manager.py +26 -0
  6. klaude_code/auth/codex/token_manager.py +10 -50
  7. klaude_code/cli/auth_cmd.py +127 -46
  8. klaude_code/cli/config_cmd.py +4 -2
  9. klaude_code/cli/cost_cmd.py +14 -9
  10. klaude_code/cli/list_model.py +248 -200
  11. klaude_code/cli/main.py +1 -1
  12. klaude_code/cli/runtime.py +7 -5
  13. klaude_code/cli/self_update.py +1 -1
  14. klaude_code/cli/session_cmd.py +1 -1
  15. klaude_code/command/clear_cmd.py +6 -2
  16. klaude_code/command/command_abc.py +2 -2
  17. klaude_code/command/debug_cmd.py +4 -4
  18. klaude_code/command/export_cmd.py +2 -2
  19. klaude_code/command/export_online_cmd.py +12 -12
  20. klaude_code/command/fork_session_cmd.py +29 -23
  21. klaude_code/command/help_cmd.py +4 -4
  22. klaude_code/command/model_cmd.py +4 -4
  23. klaude_code/command/model_select.py +1 -1
  24. klaude_code/command/prompt-commit.md +82 -0
  25. klaude_code/command/prompt_command.py +3 -3
  26. klaude_code/command/refresh_cmd.py +2 -2
  27. klaude_code/command/registry.py +7 -5
  28. klaude_code/command/release_notes_cmd.py +4 -4
  29. klaude_code/command/resume_cmd.py +15 -11
  30. klaude_code/command/status_cmd.py +4 -4
  31. klaude_code/command/terminal_setup_cmd.py +8 -8
  32. klaude_code/command/thinking_cmd.py +4 -4
  33. klaude_code/config/assets/builtin_config.yaml +52 -3
  34. klaude_code/config/builtin_config.py +16 -5
  35. klaude_code/config/config.py +31 -7
  36. klaude_code/config/thinking.py +4 -4
  37. klaude_code/const.py +146 -91
  38. klaude_code/core/agent.py +3 -12
  39. klaude_code/core/executor.py +21 -13
  40. klaude_code/core/manager/sub_agent_manager.py +71 -7
  41. klaude_code/core/prompt.py +1 -1
  42. klaude_code/core/prompts/prompt-sub-agent-image-gen.md +1 -0
  43. klaude_code/core/prompts/prompt-sub-agent-web.md +27 -1
  44. klaude_code/core/reminders.py +88 -69
  45. klaude_code/core/task.py +44 -45
  46. klaude_code/core/tool/file/apply_patch_tool.py +9 -9
  47. klaude_code/core/tool/file/diff_builder.py +3 -5
  48. klaude_code/core/tool/file/edit_tool.py +23 -23
  49. klaude_code/core/tool/file/move_tool.py +43 -43
  50. klaude_code/core/tool/file/read_tool.py +44 -39
  51. klaude_code/core/tool/file/write_tool.py +14 -14
  52. klaude_code/core/tool/report_back_tool.py +4 -4
  53. klaude_code/core/tool/shell/bash_tool.py +23 -23
  54. klaude_code/core/tool/skill/skill_tool.py +7 -7
  55. klaude_code/core/tool/sub_agent_tool.py +38 -9
  56. klaude_code/core/tool/todo/todo_write_tool.py +8 -8
  57. klaude_code/core/tool/todo/update_plan_tool.py +6 -6
  58. klaude_code/core/tool/tool_abc.py +2 -2
  59. klaude_code/core/tool/tool_context.py +27 -0
  60. klaude_code/core/tool/tool_runner.py +88 -42
  61. klaude_code/core/tool/truncation.py +38 -20
  62. klaude_code/core/tool/web/mermaid_tool.py +6 -7
  63. klaude_code/core/tool/web/web_fetch_tool.py +68 -30
  64. klaude_code/core/tool/web/web_search_tool.py +15 -17
  65. klaude_code/core/turn.py +120 -73
  66. klaude_code/llm/anthropic/client.py +104 -44
  67. klaude_code/llm/anthropic/input.py +116 -108
  68. klaude_code/llm/bedrock/client.py +8 -5
  69. klaude_code/llm/claude/__init__.py +3 -0
  70. klaude_code/llm/claude/client.py +105 -0
  71. klaude_code/llm/client.py +4 -3
  72. klaude_code/llm/codex/client.py +16 -10
  73. klaude_code/llm/google/client.py +122 -60
  74. klaude_code/llm/google/input.py +94 -108
  75. klaude_code/llm/image.py +123 -0
  76. klaude_code/llm/input_common.py +136 -189
  77. klaude_code/llm/openai_compatible/client.py +17 -7
  78. klaude_code/llm/openai_compatible/input.py +36 -66
  79. klaude_code/llm/openai_compatible/stream.py +119 -67
  80. klaude_code/llm/openai_compatible/tool_call_accumulator.py +23 -11
  81. klaude_code/llm/openrouter/client.py +34 -9
  82. klaude_code/llm/openrouter/input.py +63 -64
  83. klaude_code/llm/openrouter/reasoning.py +22 -24
  84. klaude_code/llm/registry.py +20 -15
  85. klaude_code/llm/responses/client.py +107 -45
  86. klaude_code/llm/responses/input.py +115 -98
  87. klaude_code/llm/usage.py +52 -25
  88. klaude_code/protocol/__init__.py +1 -0
  89. klaude_code/protocol/events.py +16 -12
  90. klaude_code/protocol/llm_param.py +22 -3
  91. klaude_code/protocol/message.py +250 -0
  92. klaude_code/protocol/model.py +94 -281
  93. klaude_code/protocol/op.py +2 -2
  94. klaude_code/protocol/sub_agent/__init__.py +2 -2
  95. klaude_code/protocol/sub_agent/explore.py +10 -0
  96. klaude_code/protocol/sub_agent/image_gen.py +119 -0
  97. klaude_code/protocol/sub_agent/task.py +10 -0
  98. klaude_code/protocol/sub_agent/web.py +10 -0
  99. klaude_code/session/codec.py +6 -6
  100. klaude_code/session/export.py +261 -62
  101. klaude_code/session/selector.py +7 -24
  102. klaude_code/session/session.py +125 -53
  103. klaude_code/session/store.py +5 -32
  104. klaude_code/session/templates/export_session.html +1 -1
  105. klaude_code/session/templates/mermaid_viewer.html +1 -1
  106. klaude_code/trace/log.py +11 -6
  107. klaude_code/ui/core/input.py +1 -1
  108. klaude_code/ui/core/stage_manager.py +1 -8
  109. klaude_code/ui/modes/debug/display.py +2 -2
  110. klaude_code/ui/modes/repl/clipboard.py +2 -2
  111. klaude_code/ui/modes/repl/completers.py +18 -10
  112. klaude_code/ui/modes/repl/event_handler.py +136 -127
  113. klaude_code/ui/modes/repl/input_prompt_toolkit.py +1 -1
  114. klaude_code/ui/modes/repl/key_bindings.py +1 -1
  115. klaude_code/ui/modes/repl/renderer.py +107 -15
  116. klaude_code/ui/renderers/assistant.py +2 -2
  117. klaude_code/ui/renderers/common.py +65 -7
  118. klaude_code/ui/renderers/developer.py +7 -6
  119. klaude_code/ui/renderers/diffs.py +11 -11
  120. klaude_code/ui/renderers/mermaid_viewer.py +49 -2
  121. klaude_code/ui/renderers/metadata.py +39 -31
  122. klaude_code/ui/renderers/sub_agent.py +57 -16
  123. klaude_code/ui/renderers/thinking.py +37 -2
  124. klaude_code/ui/renderers/tools.py +180 -165
  125. klaude_code/ui/rich/live.py +3 -1
  126. klaude_code/ui/rich/markdown.py +39 -7
  127. klaude_code/ui/rich/quote.py +76 -1
  128. klaude_code/ui/rich/status.py +14 -8
  129. klaude_code/ui/rich/theme.py +13 -6
  130. klaude_code/ui/terminal/image.py +34 -0
  131. klaude_code/ui/terminal/notifier.py +2 -1
  132. klaude_code/ui/terminal/progress_bar.py +4 -4
  133. klaude_code/ui/terminal/selector.py +22 -4
  134. klaude_code/ui/utils/common.py +55 -0
  135. {klaude_code-1.8.0.dist-info → klaude_code-2.0.0.dist-info}/METADATA +28 -6
  136. klaude_code-2.0.0.dist-info/RECORD +229 -0
  137. klaude_code/command/prompt-jj-describe.md +0 -32
  138. klaude_code/core/prompts/prompt-sub-agent-oracle.md +0 -22
  139. klaude_code/protocol/sub_agent/oracle.py +0 -91
  140. klaude_code-1.8.0.dist-info/RECORD +0 -219
  141. {klaude_code-1.8.0.dist-info → klaude_code-2.0.0.dist-info}/WHEEL +0 -0
  142. {klaude_code-1.8.0.dist-info → klaude_code-2.0.0.dist-info}/entry_points.txt +0 -0
@@ -9,16 +9,13 @@ from typing import Any, cast
9
9
 
10
10
  from pydantic import BaseModel, Field, PrivateAttr, ValidationError
11
11
 
12
- from klaude_code.protocol import events, llm_param, model, tools
13
- from klaude_code.session.store import JsonlSessionStore, ProjectPaths, build_meta_snapshot
12
+ from klaude_code.const import ProjectPaths, project_key_from_cwd
13
+ from klaude_code.protocol import events, llm_param, message, model, tools
14
+ from klaude_code.session.store import JsonlSessionStore, build_meta_snapshot
14
15
 
15
16
  _DEFAULT_STORES: dict[str, JsonlSessionStore] = {}
16
17
 
17
18
 
18
- def _project_key_from_cwd() -> str:
19
- return str(Path.cwd()).strip("/").replace("/", "-")
20
-
21
-
22
19
  def _read_json_dict(path: Path) -> dict[str, Any] | None:
23
20
  try:
24
21
  raw = json.loads(path.read_text(encoding="utf-8"))
@@ -30,7 +27,7 @@ def _read_json_dict(path: Path) -> dict[str, Any] | None:
30
27
 
31
28
 
32
29
  def get_default_store() -> JsonlSessionStore:
33
- project_key = _project_key_from_cwd()
30
+ project_key = project_key_from_cwd()
34
31
  store = _DEFAULT_STORES.get(project_key)
35
32
  if store is None:
36
33
  store = JsonlSessionStore(project_key=project_key)
@@ -48,7 +45,7 @@ async def close_default_store() -> None:
48
45
  class Session(BaseModel):
49
46
  id: str = Field(default_factory=lambda: uuid.uuid4().hex)
50
47
  work_dir: Path
51
- conversation_history: list[model.ConversationItem] = Field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
48
+ conversation_history: list[message.HistoryEvent] = Field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
52
49
  sub_agent_state: model.SubAgentState | None = None
53
50
  file_tracker: dict[str, model.FileStatus] = Field(default_factory=dict)
54
51
  todos: list[model.TodoItem] = Field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
@@ -67,12 +64,12 @@ class Session(BaseModel):
67
64
 
68
65
  @property
69
66
  def messages_count(self) -> int:
70
- """Count of user, assistant messages, and tool calls in conversation history."""
67
+ """Count of user, assistant messages, and tool results in conversation history."""
71
68
  if self._messages_count_cache is None:
72
69
  self._messages_count_cache = sum(
73
70
  1
74
71
  for it in self.conversation_history
75
- if isinstance(it, (model.UserMessageItem, model.AssistantMessageItem, model.ToolCallItem))
72
+ if isinstance(it, (message.UserMessage, message.AssistantMessage, message.ToolResultMessage))
76
73
  )
77
74
  return self._messages_count_cache
78
75
 
@@ -89,13 +86,15 @@ class Session(BaseModel):
89
86
 
90
87
  if self._user_messages_cache is None:
91
88
  self._user_messages_cache = [
92
- it.content for it in self.conversation_history if isinstance(it, model.UserMessageItem) and it.content
89
+ message.join_text_parts(it.parts)
90
+ for it in self.conversation_history
91
+ if isinstance(it, message.UserMessage) and message.join_text_parts(it.parts)
93
92
  ]
94
93
  return self._user_messages_cache
95
94
 
96
95
  @staticmethod
97
96
  def _project_key() -> str:
98
- return _project_key_from_cwd()
97
+ return project_key_from_cwd()
99
98
 
100
99
  @classmethod
101
100
  def paths(cls) -> ProjectPaths:
@@ -186,7 +185,7 @@ class Session(BaseModel):
186
185
  session.conversation_history = store.load_history(id)
187
186
  return session
188
187
 
189
- def append_history(self, items: Sequence[model.ConversationItem]) -> None:
188
+ def append_history(self, items: Sequence[message.HistoryEvent]) -> None:
190
189
  if not items:
191
190
  return
192
191
 
@@ -194,13 +193,17 @@ class Session(BaseModel):
194
193
  self._invalidate_messages_count_cache()
195
194
 
196
195
  new_user_messages = [
197
- it.content for it in items if isinstance(it, model.UserMessageItem) and it.content
196
+ message.join_text_parts(it.parts)
197
+ for it in items
198
+ if isinstance(it, message.UserMessage) and message.join_text_parts(it.parts)
198
199
  ]
199
200
  if new_user_messages:
200
201
  if self._user_messages_cache is None:
201
202
  # Build from full history once to ensure correctness when resuming older sessions.
202
203
  self._user_messages_cache = [
203
- it.content for it in self.conversation_history if isinstance(it, model.UserMessageItem) and it.content
204
+ message.join_text_parts(it.parts)
205
+ for it in self.conversation_history
206
+ if isinstance(it, message.UserMessage) and message.join_text_parts(it.parts)
204
207
  ]
205
208
  else:
206
209
  self._user_messages_cache.extend(new_user_messages)
@@ -279,69 +282,91 @@ class Session(BaseModel):
279
282
  latest_id = sid
280
283
  return latest_id
281
284
 
282
- def need_turn_start(self, prev_item: model.ConversationItem | None, item: model.ConversationItem) -> bool:
283
- if not isinstance(
284
- item,
285
- model.ReasoningTextItem | model.AssistantMessageItem | model.ToolCallItem,
286
- ):
285
+ def need_turn_start(self, prev_item: message.HistoryEvent | None, item: message.HistoryEvent) -> bool:
286
+ if not isinstance(item, message.AssistantMessage):
287
287
  return False
288
288
  if prev_item is None:
289
289
  return True
290
- return isinstance(prev_item, model.UserMessageItem | model.ToolResultItem | model.DeveloperMessageItem)
290
+ return isinstance(prev_item, (message.UserMessage, message.ToolResultMessage, message.DeveloperMessage))
291
291
 
292
292
  def get_history_item(self) -> Iterable[events.HistoryItemEvent]:
293
293
  seen_sub_agent_sessions: set[str] = set()
294
- prev_item: model.ConversationItem | None = None
294
+ prev_item: message.HistoryEvent | None = None
295
295
  last_assistant_content: str = ""
296
296
  report_back_result: str | None = None
297
+ history = self.conversation_history
298
+ history_len = len(history)
297
299
  yield events.TaskStartEvent(session_id=self.id, sub_agent_state=self.sub_agent_state)
298
- for it in self.conversation_history:
300
+ for idx, it in enumerate(history):
299
301
  if self.need_turn_start(prev_item, it):
300
302
  yield events.TurnStartEvent(session_id=self.id)
301
303
  match it:
302
- case model.AssistantMessageItem() as am:
303
- content = am.content or ""
304
- last_assistant_content = content
304
+ case message.AssistantMessage() as am:
305
+ content = message.join_text_parts(am.parts)
306
+ images = [part for part in am.parts if isinstance(part, message.ImageFilePart)]
307
+ last_assistant_content = message.format_saved_images(images, content)
308
+ thinking_text = "".join(
309
+ part.text for part in am.parts if isinstance(part, message.ThinkingTextPart)
310
+ )
311
+ for image in images:
312
+ yield events.AssistantImageDeltaEvent(
313
+ file_path=image.file_path,
314
+ response_id=am.response_id,
315
+ session_id=self.id,
316
+ )
305
317
  yield events.AssistantMessageEvent(
318
+ thinking_text=thinking_text,
306
319
  content=content,
307
320
  response_id=am.response_id,
308
321
  session_id=self.id,
309
322
  )
310
- case model.ToolCallItem() as tc:
311
- if tc.name == tools.REPORT_BACK:
312
- report_back_result = tc.arguments
313
- yield events.ToolCallEvent(
314
- tool_call_id=tc.call_id,
315
- tool_name=tc.name,
316
- arguments=tc.arguments,
317
- response_id=tc.response_id,
318
- session_id=self.id,
319
- )
320
- case model.ToolResultItem() as tr:
323
+ for part in am.parts:
324
+ if not isinstance(part, message.ToolCallPart):
325
+ continue
326
+ if part.tool_name == tools.REPORT_BACK:
327
+ report_back_result = part.arguments_json
328
+ yield events.ToolCallEvent(
329
+ tool_call_id=part.call_id,
330
+ tool_name=part.tool_name,
331
+ arguments=part.arguments_json,
332
+ response_id=am.response_id,
333
+ session_id=self.id,
334
+ )
335
+ if am.stop_reason == "aborted":
336
+ yield events.InterruptEvent(session_id=self.id)
337
+ case message.ToolResultMessage() as tr:
338
+ status = "success" if tr.status == "success" else "error"
339
+ # Check if this is the last tool result in the current turn
340
+ next_item = history[idx + 1] if idx + 1 < history_len else None
341
+ is_last_in_turn = not isinstance(next_item, message.ToolResultMessage)
321
342
  yield events.ToolResultEvent(
322
343
  tool_call_id=tr.call_id,
323
344
  tool_name=str(tr.tool_name),
324
- result=tr.output or "",
345
+ result=tr.output_text,
325
346
  ui_extra=tr.ui_extra,
326
347
  session_id=self.id,
327
- status=tr.status,
348
+ status=status,
328
349
  task_metadata=tr.task_metadata,
350
+ is_last_in_turn=is_last_in_turn,
329
351
  )
330
352
  yield from self._iter_sub_agent_history(tr, seen_sub_agent_sessions)
331
- case model.UserMessageItem() as um:
332
- yield events.UserMessageEvent(content=um.content or "", session_id=self.id, images=um.images)
333
- case model.ReasoningTextItem() as ri:
334
- yield events.ThinkingEvent(content=ri.content, session_id=self.id)
353
+ if tr.status == "aborted":
354
+ yield events.InterruptEvent(session_id=self.id)
355
+ case message.UserMessage() as um:
356
+ images = [part for part in um.parts if isinstance(part, message.ImageURLPart)]
357
+ yield events.UserMessageEvent(
358
+ content=message.join_text_parts(um.parts),
359
+ session_id=self.id,
360
+ images=images or None,
361
+ )
335
362
  case model.TaskMetadataItem() as mt:
336
363
  yield events.TaskMetadataEvent(session_id=self.id, metadata=mt)
337
- case model.InterruptItem():
338
- yield events.InterruptEvent(session_id=self.id)
339
- case model.DeveloperMessageItem() as dm:
364
+ case message.DeveloperMessage() as dm:
340
365
  yield events.DeveloperMessageEvent(session_id=self.id, item=dm)
341
- case model.StreamErrorItem() as se:
366
+ case message.StreamErrorItem() as se:
342
367
  yield events.ErrorEvent(error_message=se.error, can_retry=False, session_id=self.id)
343
- case _:
344
- continue
368
+ case message.SystemMessage():
369
+ pass
345
370
  prev_item = it
346
371
 
347
372
  has_structured_output = report_back_result is not None
@@ -351,7 +376,7 @@ class Session(BaseModel):
351
376
  )
352
377
 
353
378
  def _iter_sub_agent_history(
354
- self, tool_result: model.ToolResultItem, seen_sub_agent_sessions: set[str]
379
+ self, tool_result: message.ToolResultMessage, seen_sub_agent_sessions: set[str]
355
380
  ) -> Iterable[events.HistoryItemEvent]:
356
381
  ui_extra = tool_result.ui_extra
357
382
  if not isinstance(ui_extra, model.SessionIdUIExtra):
@@ -393,14 +418,18 @@ class Session(BaseModel):
393
418
  if not isinstance(obj_raw, dict):
394
419
  continue
395
420
  obj = cast(dict[str, Any], obj_raw)
396
- if obj.get("type") != "UserMessageItem":
421
+ if obj.get("type") != "UserMessage":
397
422
  continue
398
423
  data_raw = obj.get("data")
399
424
  if not isinstance(data_raw, dict):
400
425
  continue
401
426
  data = cast(dict[str, Any], data_raw)
402
- content = data.get("content")
403
- if isinstance(content, str):
427
+ try:
428
+ user_msg = message.UserMessage.model_validate(data)
429
+ except ValidationError:
430
+ continue
431
+ content = message.join_text_parts(user_msg.parts)
432
+ if content:
404
433
  messages.append(content)
405
434
  except (OSError, json.JSONDecodeError):
406
435
  pass
@@ -457,6 +486,49 @@ class Session(BaseModel):
457
486
  items.sort(key=lambda d: d.updated_at, reverse=True)
458
487
  return items
459
488
 
489
+ @classmethod
490
+ def resolve_sub_agent_session_id(cls, resume: str) -> str:
491
+ """Resolve a sub-agent session id from an id prefix.
492
+
493
+ Args:
494
+ resume: Full session id or a unique prefix.
495
+
496
+ Returns:
497
+ The resolved full session id.
498
+
499
+ Raises:
500
+ ValueError: If resume is empty, not found, or ambiguous.
501
+ """
502
+
503
+ prefix = (resume or "").strip().lower()
504
+ if not prefix:
505
+ raise ValueError("resume cannot be empty")
506
+
507
+ store = get_default_store()
508
+ matches: set[str] = set()
509
+
510
+ for meta_path in store.iter_meta_files():
511
+ data = _read_json_dict(meta_path)
512
+ if data is None:
513
+ continue
514
+ # Only allow resuming sub-agent sessions.
515
+ if data.get("sub_agent_state") is None:
516
+ continue
517
+ sid = str(data.get("id", meta_path.parent.name)).strip()
518
+ if sid.lower().startswith(prefix):
519
+ matches.add(sid)
520
+
521
+ if not matches:
522
+ raise ValueError(f"resume id not found for this project: '{resume}'")
523
+
524
+ resolved = sorted(matches)
525
+ if len(resolved) > 1:
526
+ sample = ", ".join(resolved[:8])
527
+ suffix = "" if len(resolved) <= 8 else f" (+{len(resolved) - 8} more)"
528
+ raise ValueError(f"resume id is ambiguous: '{resume}' matches {sample}{suffix}")
529
+
530
+ return resolved[0]
531
+
460
532
  @classmethod
461
533
  def clean_small_sessions(cls, min_messages: int = 5) -> int:
462
534
  sessions = cls.list_sessions()
@@ -8,36 +8,11 @@ from dataclasses import dataclass
8
8
  from pathlib import Path
9
9
  from typing import Any, cast
10
10
 
11
- from klaude_code.protocol import llm_param, model
11
+ from klaude_code.const import ProjectPaths
12
+ from klaude_code.protocol import llm_param, message, model
12
13
  from klaude_code.session.codec import decode_jsonl_line, encode_jsonl_line
13
14
 
14
15
 
15
- @dataclass(frozen=True)
16
- class ProjectPaths:
17
- project_key: str
18
-
19
- @property
20
- def base_dir(self) -> Path:
21
- return Path.home() / ".klaude" / "projects" / self.project_key
22
-
23
- @property
24
- def sessions_dir(self) -> Path:
25
- return self.base_dir / "sessions"
26
-
27
- @property
28
- def exports_dir(self) -> Path:
29
- return self.base_dir / "exports"
30
-
31
- def session_dir(self, session_id: str) -> Path:
32
- return self.sessions_dir / session_id
33
-
34
- def events_file(self, session_id: str) -> Path:
35
- return self.session_dir(session_id) / "events.jsonl"
36
-
37
- def meta_file(self, session_id: str) -> Path:
38
- return self.session_dir(session_id) / "meta.json"
39
-
40
-
41
16
  class _WriterClosedError(RuntimeError):
42
17
  pass
43
18
 
@@ -135,7 +110,7 @@ class JsonlSessionStore:
135
110
  return None
136
111
  return cast(dict[str, Any], raw) if isinstance(raw, dict) else None
137
112
 
138
- def load_history(self, session_id: str) -> list[model.ConversationItem]:
113
+ def load_history(self, session_id: str) -> list[message.HistoryEvent]:
139
114
  events_path = self._paths.events_file(session_id)
140
115
  if not events_path.exists():
141
116
  return []
@@ -143,7 +118,7 @@ class JsonlSessionStore:
143
118
  lines = events_path.read_text(encoding="utf-8").splitlines()
144
119
  except OSError:
145
120
  return []
146
- items: list[model.ConversationItem] = []
121
+ items: list[message.HistoryEvent] = []
147
122
  for line in lines:
148
123
  item = decode_jsonl_line(line)
149
124
  if item is None:
@@ -151,9 +126,7 @@ class JsonlSessionStore:
151
126
  items.append(item)
152
127
  return items
153
128
 
154
- def append_and_flush(
155
- self, *, session_id: str, items: Sequence[model.ConversationItem], meta: dict[str, Any]
156
- ) -> None:
129
+ def append_and_flush(self, *, session_id: str, items: Sequence[message.HistoryEvent], meta: dict[str, Any]) -> None:
157
130
  if not items:
158
131
  return
159
132
  loop = asyncio.get_running_loop()
@@ -1853,7 +1853,7 @@
1853
1853
  ) {
1854
1854
  let desc = args.description.trim();
1855
1855
  if (desc.length > 30) {
1856
- desc = desc.substring(0, 30) + "...";
1856
+ desc = desc.substring(0, 30) + "";
1857
1857
  }
1858
1858
  label = label + " - " + desc;
1859
1859
  }
@@ -485,7 +485,7 @@
485
485
  <textarea
486
486
  id="source-code"
487
487
  spellcheck="false"
488
- placeholder="Enter mermaid diagram code..."
488
+ placeholder="Enter mermaid diagram code"
489
489
  >
490
490
  __KLAUDE_CODE__</textarea
491
491
  >
klaude_code/trace/log.py CHANGED
@@ -13,7 +13,12 @@ from rich.console import Console
13
13
  from rich.logging import RichHandler
14
14
  from rich.text import Text
15
15
 
16
- from klaude_code import const
16
+ from klaude_code.const import (
17
+ DEFAULT_DEBUG_LOG_DIR,
18
+ DEFAULT_DEBUG_LOG_FILE,
19
+ LOG_BACKUP_COUNT,
20
+ LOG_MAX_BYTES,
21
+ )
17
22
 
18
23
  # Module-level logger
19
24
  logger = logging.getLogger("klaude_code")
@@ -123,13 +128,13 @@ def set_debug_logging(
123
128
  file_path = None
124
129
 
125
130
  if use_file and file_path is not None:
126
- _prune_old_logs(const.DEFAULT_DEBUG_LOG_DIR, LOG_RETENTION_DAYS, LOG_MAX_TOTAL_BYTES)
131
+ _prune_old_logs(DEFAULT_DEBUG_LOG_DIR, LOG_RETENTION_DAYS, LOG_MAX_TOTAL_BYTES)
127
132
 
128
133
  if use_file and file_path is not None:
129
134
  _file_handler = GzipRotatingFileHandler(
130
135
  file_path,
131
- maxBytes=const.LOG_MAX_BYTES,
132
- backupCount=const.LOG_BACKUP_COUNT,
136
+ maxBytes=LOG_MAX_BYTES,
137
+ backupCount=LOG_BACKUP_COUNT,
133
138
  encoding="utf-8",
134
139
  )
135
140
  _file_handler.setLevel(logging.DEBUG)
@@ -238,7 +243,7 @@ def _build_default_log_file_path() -> Path:
238
243
  """Build a per-session log path under the default log directory."""
239
244
 
240
245
  now = datetime.now()
241
- session_dir = const.DEFAULT_DEBUG_LOG_DIR / now.strftime("%Y-%m-%d")
246
+ session_dir = DEFAULT_DEBUG_LOG_DIR / now.strftime("%Y-%m-%d")
242
247
  session_dir.mkdir(parents=True, exist_ok=True)
243
248
  filename = f"{now.strftime('%H%M%S')}-{os.getpid()}.log"
244
249
  return session_dir / filename
@@ -247,7 +252,7 @@ def _build_default_log_file_path() -> Path:
247
252
  def _refresh_latest_symlink(target: Path) -> None:
248
253
  """Point the debug.log symlink at the latest session file."""
249
254
 
250
- latest = const.DEFAULT_DEBUG_LOG_FILE
255
+ latest = DEFAULT_DEBUG_LOG_FILE
251
256
  try:
252
257
  latest.unlink(missing_ok=True)
253
258
  latest.symlink_to(target)
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  from abc import ABC, abstractmethod
4
4
  from collections.abc import AsyncIterator
5
5
 
6
- from klaude_code.protocol.model import UserInputPayload
6
+ from klaude_code.protocol.message import UserInputPayload
7
7
 
8
8
 
9
9
  class InputProviderABC(ABC):
@@ -40,16 +40,9 @@ class StageManager:
40
40
  return
41
41
  await self.transition_to(Stage.THINKING)
42
42
 
43
- async def finish_assistant(self) -> None:
44
- if self._stage != Stage.ASSISTANT:
45
- await self._finish_assistant()
46
- return
47
- await self._finish_assistant()
48
- self._stage = Stage.WAITING
49
-
50
43
  async def _leave_current_stage(self) -> None:
51
44
  if self._stage == Stage.THINKING:
52
45
  await self._finish_thinking()
53
46
  elif self._stage == Stage.ASSISTANT:
54
- await self.finish_assistant()
47
+ await self._finish_assistant()
55
48
  self._stage = Stage.WAITING
@@ -1,7 +1,7 @@
1
1
  import os
2
2
  from typing import override
3
3
 
4
- from klaude_code import const
4
+ from klaude_code.const import DEFAULT_DEBUG_LOG_FILE
5
5
  from klaude_code.protocol import events
6
6
  from klaude_code.trace import DebugType, log_debug
7
7
  from klaude_code.ui.core.display import DisplayABC
@@ -11,7 +11,7 @@ class DebugEventDisplay(DisplayABC):
11
11
  def __init__(
12
12
  self,
13
13
  wrapped_display: DisplayABC | None = None,
14
- log_file: str | os.PathLike[str] = const.DEFAULT_DEBUG_LOG_FILE,
14
+ log_file: str | os.PathLike[str] = DEFAULT_DEBUG_LOG_FILE,
15
15
  ):
16
16
  self.wrapped_display = wrapped_display
17
17
  self.log_file = log_file
@@ -19,7 +19,7 @@ from pathlib import Path
19
19
 
20
20
  from PIL import Image, ImageGrab
21
21
 
22
- from klaude_code.protocol.model import ImageURLPart
22
+ from klaude_code.protocol.message import ImageURLPart
23
23
 
24
24
  # Directory for storing clipboard images
25
25
  CLIPBOARD_IMAGES_DIR = Path.home() / ".klaude" / "clipboard" / "images"
@@ -122,7 +122,7 @@ def _encode_image_file(file_path: str) -> ImageURLPart | None:
122
122
  encoded = b64encode(f.read()).decode("ascii")
123
123
  # Clipboard images are always saved as PNG
124
124
  data_url = f"data:image/png;base64,{encoded}"
125
- return ImageURLPart(image_url=ImageURLPart.ImageURL(url=data_url, id=None))
125
+ return ImageURLPart(url=data_url, id=None)
126
126
  except OSError:
127
127
  return None
128
128
 
@@ -27,6 +27,7 @@ from prompt_toolkit.completion import Completer, Completion
27
27
  from prompt_toolkit.document import Document
28
28
  from prompt_toolkit.formatted_text import FormattedText
29
29
 
30
+ from klaude_code.const import COMPLETER_CACHE_TTL_SEC, COMPLETER_CMD_TIMEOUT_SEC, COMPLETER_DEBOUNCE_SEC
30
31
  from klaude_code.protocol.commands import CommandInfo
31
32
  from klaude_code.trace.log import DebugType, log_debug
32
33
 
@@ -65,7 +66,7 @@ class _SlashCommandCompleter(Completer):
65
66
  """Complete slash commands at the beginning of the first line.
66
67
 
67
68
  Behavior:
68
- - Only triggers when cursor is on first line and text matches /...
69
+ - Only triggers when cursor is on first line and text matches /…
69
70
  - Shows available slash commands with descriptions
70
71
  - Inserts trailing space after completion
71
72
  """
@@ -132,7 +133,7 @@ class _SkillCompleter(Completer):
132
133
  """Complete skill names at the beginning of the first line.
133
134
 
134
135
  Behavior:
135
- - Only triggers when cursor is on first line and text matches $ or ¥...
136
+ - Only triggers when cursor is on first line and text matches $ or ¥…
136
137
  - Shows available skills with descriptions
137
138
  - Inserts trailing space after completion
138
139
  """
@@ -240,7 +241,7 @@ class _AtFilesCompleter(Completer):
240
241
  """Complete @path segments using fd or ripgrep.
241
242
 
242
243
  Behavior:
243
- - Only triggers when the cursor is after an "@..." token (until whitespace).
244
+ - Only triggers when the cursor is after an "@…" token (until whitespace).
244
245
  - Completes paths relative to the current working directory.
245
246
  - Uses `fd` when available (files and directories), falls back to `rg --files` (files only).
246
247
  - Debounces external commands and caches results to avoid excessive spawning.
@@ -251,8 +252,8 @@ class _AtFilesCompleter(Completer):
251
252
 
252
253
  def __init__(
253
254
  self,
254
- debounce_sec: float = 0.25,
255
- cache_ttl_sec: float = 60.0,
255
+ debounce_sec: float = COMPLETER_DEBOUNCE_SEC,
256
+ cache_ttl_sec: float = COMPLETER_CACHE_TTL_SEC,
256
257
  max_results: int = 20,
257
258
  ):
258
259
  self._debounce_sec = debounce_sec
@@ -283,7 +284,7 @@ class _AtFilesCompleter(Completer):
283
284
 
284
285
  # Command timeout is intentionally higher than a keypress cadence.
285
286
  # We rely on caching/narrowing to avoid calling fd repeatedly.
286
- self._cmd_timeout_sec: float = 3.0
287
+ self._cmd_timeout_sec: float = COMPLETER_CMD_TIMEOUT_SEC
287
288
 
288
289
  # ---- prompt_toolkit API ----
289
290
  def get_completions(self, document: Document, complete_event) -> Iterable[Completion]: # type: ignore[override]
@@ -293,7 +294,7 @@ class _AtFilesCompleter(Completer):
293
294
  return [] # type: ignore[reportUnknownVariableType]
294
295
 
295
296
  frag = m.group("frag") # raw text after '@' and before cursor (may be quoted)
296
- # Normalize fragment for search: support optional quoting syntax @"...".
297
+ # Normalize fragment for search: support optional quoting syntax @"".
297
298
  is_quoted = frag.startswith('"')
298
299
  search_frag = frag
299
300
  if is_quoted:
@@ -459,7 +460,7 @@ class _AtFilesCompleter(Completer):
459
460
  # 4. Basename hit first, then path hit position, then length
460
461
  # Since both fd and rg now search from current directory, all paths are relative to cwd
461
462
  kn = keyword_norm
462
- out: list[tuple[str, tuple[int, int, int, int, int, int, int]]] = []
463
+ out: list[tuple[str, tuple[int, int, int, int, int, int, int, int]]] = []
463
464
  for p in paths_from_root:
464
465
  pl = p.lower()
465
466
  if kn not in pl:
@@ -481,11 +482,18 @@ class _AtFilesCompleter(Completer):
481
482
  # Deprioritize paths containing "test"
482
483
  has_test = "test" in pl
483
484
 
485
+ # Calculate basename match quality: how close is base to the keyword?
486
+ # Strip extension for files to compare stem (e.g., "renderer.py" -> "renderer")
487
+ base_stem = base.rsplit(".", 1)[0] if "." in base and not base.startswith(".") else base
488
+ # Exact stem match gets 0, otherwise difference in length
489
+ base_match_quality = abs(len(base_stem) - len(kn)) if base_pos != -1 else 10_000
490
+
484
491
  score = (
485
492
  1 if is_hidden else 0,
486
493
  1 if has_test else 0,
487
- depth,
488
- 0 if base_pos != -1 else 1,
494
+ 0 if base_pos != -1 else 1, # basename match first
495
+ base_match_quality, # more precise basename match wins
496
+ depth, # then shallower paths
489
497
  base_pos if base_pos != -1 else 10_000,
490
498
  path_pos,
491
499
  len(p),