klaude-code 1.2.12__py3-none-any.whl → 1.2.14__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 (84) hide show
  1. klaude_code/auth/codex/oauth.py +3 -3
  2. klaude_code/cli/auth_cmd.py +73 -0
  3. klaude_code/cli/config_cmd.py +88 -0
  4. klaude_code/cli/debug.py +72 -0
  5. klaude_code/cli/main.py +31 -142
  6. klaude_code/cli/runtime.py +19 -58
  7. klaude_code/cli/session_cmd.py +9 -9
  8. klaude_code/command/__init__.py +6 -6
  9. klaude_code/command/export_cmd.py +3 -3
  10. klaude_code/command/model_cmd.py +1 -1
  11. klaude_code/command/registry.py +1 -1
  12. klaude_code/command/terminal_setup_cmd.py +2 -2
  13. klaude_code/command/thinking_cmd.py +8 -6
  14. klaude_code/config/__init__.py +1 -5
  15. klaude_code/config/config.py +31 -4
  16. klaude_code/config/list_model.py +1 -1
  17. klaude_code/const/__init__.py +8 -3
  18. klaude_code/core/agent.py +14 -62
  19. klaude_code/core/executor.py +11 -10
  20. klaude_code/core/manager/agent_manager.py +4 -4
  21. klaude_code/core/manager/llm_clients.py +10 -49
  22. klaude_code/core/manager/llm_clients_builder.py +8 -21
  23. klaude_code/core/manager/sub_agent_manager.py +3 -3
  24. klaude_code/core/prompt.py +12 -7
  25. klaude_code/core/reminders.py +1 -1
  26. klaude_code/core/task.py +2 -2
  27. klaude_code/core/tool/__init__.py +16 -25
  28. klaude_code/core/tool/file/_utils.py +1 -1
  29. klaude_code/core/tool/file/apply_patch.py +17 -25
  30. klaude_code/core/tool/file/apply_patch_tool.py +4 -7
  31. klaude_code/core/tool/file/edit_tool.py +4 -11
  32. klaude_code/core/tool/file/multi_edit_tool.py +2 -3
  33. klaude_code/core/tool/file/read_tool.py +3 -4
  34. klaude_code/core/tool/file/write_tool.py +2 -3
  35. klaude_code/core/tool/memory/memory_tool.py +2 -8
  36. klaude_code/core/tool/memory/skill_loader.py +3 -2
  37. klaude_code/core/tool/shell/command_safety.py +0 -1
  38. klaude_code/core/tool/tool_context.py +1 -3
  39. klaude_code/core/tool/tool_registry.py +2 -1
  40. klaude_code/core/tool/tool_runner.py +1 -1
  41. klaude_code/core/tool/truncation.py +2 -5
  42. klaude_code/core/turn.py +9 -3
  43. klaude_code/llm/anthropic/client.py +6 -2
  44. klaude_code/llm/client.py +5 -1
  45. klaude_code/llm/codex/client.py +2 -2
  46. klaude_code/llm/input_common.py +2 -2
  47. klaude_code/llm/openai_compatible/client.py +11 -8
  48. klaude_code/llm/openai_compatible/stream_processor.py +2 -1
  49. klaude_code/llm/openrouter/client.py +22 -9
  50. klaude_code/llm/openrouter/reasoning_handler.py +19 -132
  51. klaude_code/llm/registry.py +6 -5
  52. klaude_code/llm/responses/client.py +10 -5
  53. klaude_code/protocol/events.py +9 -2
  54. klaude_code/protocol/model.py +7 -1
  55. klaude_code/protocol/sub_agent.py +2 -2
  56. klaude_code/session/export.py +58 -0
  57. klaude_code/session/selector.py +2 -2
  58. klaude_code/session/session.py +37 -7
  59. klaude_code/session/templates/export_session.html +46 -0
  60. klaude_code/trace/__init__.py +2 -2
  61. klaude_code/trace/log.py +144 -5
  62. klaude_code/ui/__init__.py +4 -9
  63. klaude_code/ui/core/stage_manager.py +7 -4
  64. klaude_code/ui/modes/debug/display.py +2 -1
  65. klaude_code/ui/modes/repl/__init__.py +1 -1
  66. klaude_code/ui/modes/repl/completers.py +6 -7
  67. klaude_code/ui/modes/repl/display.py +3 -4
  68. klaude_code/ui/modes/repl/event_handler.py +63 -5
  69. klaude_code/ui/modes/repl/key_bindings.py +2 -3
  70. klaude_code/ui/modes/repl/renderer.py +52 -62
  71. klaude_code/ui/renderers/diffs.py +1 -4
  72. klaude_code/ui/renderers/tools.py +4 -0
  73. klaude_code/ui/rich/markdown.py +3 -3
  74. klaude_code/ui/rich/searchable_text.py +6 -6
  75. klaude_code/ui/rich/status.py +3 -4
  76. klaude_code/ui/rich/theme.py +2 -5
  77. klaude_code/ui/terminal/control.py +7 -16
  78. klaude_code/ui/terminal/notifier.py +2 -4
  79. klaude_code/ui/utils/common.py +1 -1
  80. klaude_code/ui/utils/debouncer.py +2 -2
  81. {klaude_code-1.2.12.dist-info → klaude_code-1.2.14.dist-info}/METADATA +1 -1
  82. {klaude_code-1.2.12.dist-info → klaude_code-1.2.14.dist-info}/RECORD +84 -81
  83. {klaude_code-1.2.12.dist-info → klaude_code-1.2.14.dist-info}/WHEEL +0 -0
  84. {klaude_code-1.2.12.dist-info → klaude_code-1.2.14.dist-info}/entry_points.txt +0 -0
@@ -261,15 +261,16 @@ class Session(BaseModel):
261
261
  return False
262
262
  if prev_item is None:
263
263
  return True
264
- if isinstance(
264
+ return isinstance(
265
265
  prev_item,
266
266
  model.UserMessageItem | model.ToolResultItem | model.DeveloperMessageItem,
267
- ):
268
- return True
269
- return False
267
+ )
270
268
 
271
269
  def get_history_item(self) -> Iterable[events.HistoryItemEvent]:
270
+ seen_sub_agent_sessions: set[str] = set()
272
271
  prev_item: model.ConversationItem | None = None
272
+ last_assistant_content: str = ""
273
+ yield events.TaskStartEvent(session_id=self.id, sub_agent_state=self.sub_agent_state)
273
274
  for it in self.conversation_history:
274
275
  if self.need_turn_start(prev_item, it):
275
276
  yield events.TurnStartEvent(
@@ -278,6 +279,7 @@ class Session(BaseModel):
278
279
  match it:
279
280
  case model.AssistantMessageItem() as am:
280
281
  content = am.content or ""
282
+ last_assistant_content = content
281
283
  yield events.AssistantMessageEvent(
282
284
  content=content,
283
285
  response_id=am.response_id,
@@ -290,7 +292,6 @@ class Session(BaseModel):
290
292
  arguments=tc.arguments,
291
293
  response_id=tc.response_id,
292
294
  session_id=self.id,
293
- is_replay=True,
294
295
  )
295
296
  case model.ToolResultItem() as tr:
296
297
  yield events.ToolResultEvent(
@@ -300,9 +301,9 @@ class Session(BaseModel):
300
301
  ui_extra=tr.ui_extra,
301
302
  session_id=self.id,
302
303
  status=tr.status,
303
- is_replay=True,
304
+ task_metadata=tr.task_metadata,
304
305
  )
305
- # TODO: Replay Sub-Agent Events
306
+ yield from self._iter_sub_agent_history(tr, seen_sub_agent_sessions)
306
307
  case model.UserMessageItem() as um:
307
308
  yield events.UserMessageEvent(
308
309
  content=um.content or "",
@@ -335,6 +336,35 @@ class Session(BaseModel):
335
336
  case _:
336
337
  continue
337
338
  prev_item = it
339
+ yield events.TaskFinishEvent(session_id=self.id, task_result=last_assistant_content)
340
+
341
+ def _iter_sub_agent_history(
342
+ self, tool_result: model.ToolResultItem, seen_sub_agent_sessions: set[str]
343
+ ) -> Iterable[events.HistoryItemEvent]:
344
+ """Replay sub-agent session history when a tool result references it.
345
+
346
+ Sub-agent tool results embed a SessionIdUIExtra containing the child session ID.
347
+ When present, we load that session and yield its history events so replay/export
348
+ can show the full sub-agent transcript instead of only the summarized tool output.
349
+ """
350
+ ui_extra = tool_result.ui_extra
351
+ if not isinstance(ui_extra, model.SessionIdUIExtra):
352
+ return
353
+
354
+ session_id = ui_extra.session_id
355
+ if not session_id or session_id == self.id:
356
+ return
357
+ if session_id in seen_sub_agent_sessions:
358
+ return
359
+
360
+ seen_sub_agent_sessions.add(session_id)
361
+
362
+ try:
363
+ sub_session = Session.load(session_id)
364
+ except Exception:
365
+ return
366
+
367
+ yield from sub_session.get_history_item()
338
368
 
339
369
  class SessionMetaBrief(BaseModel):
340
370
  id: str
@@ -886,6 +886,52 @@
886
886
  color: var(--text-dim);
887
887
  }
888
888
 
889
+ /* Sub-agent Session */
890
+ details.sub-agent-session {
891
+ background: transparent;
892
+ margin: 16px 0;
893
+ border-left: 3px solid var(--accent);
894
+ padding-left: 16px;
895
+ }
896
+ details.sub-agent-session > summary {
897
+ padding: 4px 0;
898
+ font-family: var(--font-mono);
899
+ font-size: var(--font-size-sm);
900
+ text-transform: uppercase;
901
+ font-weight: var(--font-weight-bold);
902
+ cursor: pointer;
903
+ user-select: none;
904
+ list-style: none;
905
+ display: flex;
906
+ align-items: center;
907
+ gap: 8px;
908
+ min-height: 24px;
909
+ line-height: 1.2;
910
+ color: var(--text-dim);
911
+ transition: color 0.2s;
912
+ }
913
+ details.sub-agent-session > summary:hover,
914
+ details.sub-agent-session[open] > summary {
915
+ color: var(--text);
916
+ }
917
+ details.sub-agent-session > summary::-webkit-details-marker {
918
+ display: none;
919
+ }
920
+ details.sub-agent-session > summary::before {
921
+ content: "[+]";
922
+ color: var(--accent);
923
+ font-family: var(--font-mono);
924
+ margin-right: 4px;
925
+ display: inline-block;
926
+ min-width: 24px;
927
+ }
928
+ details.sub-agent-session[open] > summary::before {
929
+ content: "[-]";
930
+ }
931
+ details.sub-agent-session > .sub-agent-content {
932
+ padding: 8px 0 0 0;
933
+ }
934
+
889
935
  /* Scroll to Bottom Button */
890
936
  .scroll-btn {
891
937
  position: fixed;
@@ -1,3 +1,3 @@
1
- from .log import DebugType, is_debug_enabled, log, log_debug, logger, set_debug_logging
1
+ from .log import DebugType, is_debug_enabled, log, log_debug, logger, prepare_debug_log_file, set_debug_logging
2
2
 
3
- __all__ = ["log", "log_debug", "logger", "set_debug_logging", "DebugType", "is_debug_enabled"]
3
+ __all__ = ["DebugType", "is_debug_enabled", "log", "log_debug", "logger", "prepare_debug_log_file", "set_debug_logging"]
klaude_code/trace/log.py CHANGED
@@ -1,7 +1,13 @@
1
+ import gzip
1
2
  import logging
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ from collections.abc import Iterable
7
+ from datetime import datetime, timedelta
2
8
  from enum import Enum
3
9
  from logging.handlers import RotatingFileHandler
4
- from typing import Iterable
10
+ from pathlib import Path
5
11
 
6
12
  from rich.console import Console
7
13
  from rich.logging import RichHandler
@@ -49,6 +55,26 @@ _file_handler: RotatingFileHandler | None = None
49
55
  _console_handler: RichHandler | None = None
50
56
  _debug_filter: DebugTypeFilter | None = None
51
57
  _debug_enabled = False
58
+ _current_log_file: Path | None = None
59
+
60
+ LOG_RETENTION_DAYS = 3
61
+ LOG_MAX_TOTAL_BYTES = 200 * 1024 * 1024
62
+
63
+
64
+ class GzipRotatingFileHandler(RotatingFileHandler):
65
+ """Rotating file handler that gzips rolled files."""
66
+
67
+ def rotation_filename(self, default_name: str) -> str:
68
+ """Append .gz to rotation targets."""
69
+
70
+ return f"{default_name}.gz"
71
+
72
+ def rotate(self, source: str, dest: str) -> None:
73
+ """Compress the rotated file and remove the original."""
74
+
75
+ with open(source, "rb") as source_file, gzip.open(dest, "wb") as dest_file:
76
+ shutil.copyfileobj(source_file, dest_file)
77
+ Path(source).unlink(missing_ok=True)
52
78
 
53
79
 
54
80
  def set_debug_logging(
@@ -66,7 +92,7 @@ def set_debug_logging(
66
92
  log_file: Path to the log file (default: debug.log)
67
93
  filters: Set of DebugType to include; None means all types
68
94
  """
69
- global _file_handler, _console_handler, _debug_filter, _debug_enabled
95
+ global _file_handler, _console_handler, _debug_filter, _debug_enabled, _current_log_file
70
96
 
71
97
  _debug_enabled = enabled
72
98
 
@@ -80,6 +106,7 @@ def set_debug_logging(
80
106
  _console_handler = None
81
107
 
82
108
  if not enabled:
109
+ _current_log_file = None
83
110
  return
84
111
 
85
112
  # Create filter
@@ -87,10 +114,19 @@ def set_debug_logging(
87
114
 
88
115
  # Determine output mode
89
116
  use_file = write_to_file if write_to_file is not None else True
90
- file_path = log_file if log_file is not None else const.DEFAULT_DEBUG_LOG_FILE
91
-
92
117
  if use_file:
93
- _file_handler = RotatingFileHandler(
118
+ if _current_log_file is None:
119
+ _current_log_file = _resolve_log_file(log_file)
120
+ file_path = _current_log_file
121
+ else:
122
+ _current_log_file = None
123
+ file_path = None
124
+
125
+ 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)
127
+
128
+ if use_file and file_path is not None:
129
+ _file_handler = GzipRotatingFileHandler(
94
130
  file_path,
95
131
  maxBytes=const.LOG_MAX_BYTES,
96
132
  backupCount=const.LOG_BACKUP_COUNT,
@@ -166,3 +202,106 @@ def _build_message(objects: Iterable[str | tuple[str, str]]) -> str:
166
202
  def is_debug_enabled() -> bool:
167
203
  """Check if debug logging is currently enabled."""
168
204
  return _debug_enabled
205
+
206
+
207
+ def prepare_debug_log_file(log_file: str | os.PathLike[str] | None = None) -> Path:
208
+ """Prepare and remember the log file path for this session."""
209
+
210
+ global _current_log_file
211
+ _current_log_file = _resolve_log_file(log_file)
212
+ return _current_log_file
213
+
214
+
215
+ def get_current_log_file() -> Path | None:
216
+ """Return the currently active log file path, if any."""
217
+
218
+ return _current_log_file
219
+
220
+
221
+ def _resolve_log_file(log_file: str | os.PathLike[str] | None) -> Path:
222
+ """Resolve the log file path and ensure directories exist."""
223
+
224
+ if log_file:
225
+ path = Path(log_file).expanduser()
226
+ path.parent.mkdir(parents=True, exist_ok=True)
227
+ return path
228
+ else:
229
+ path = _build_default_log_file_path()
230
+
231
+ path.parent.mkdir(parents=True, exist_ok=True)
232
+ path.touch(exist_ok=True)
233
+ _refresh_latest_symlink(path)
234
+ return path
235
+
236
+
237
+ def _build_default_log_file_path() -> Path:
238
+ """Build a per-session log path under the default log directory."""
239
+
240
+ now = datetime.now()
241
+ session_dir = const.DEFAULT_DEBUG_LOG_DIR / now.strftime("%Y-%m-%d")
242
+ session_dir.mkdir(parents=True, exist_ok=True)
243
+ filename = f"{now.strftime('%H%M%S')}-{os.getpid()}.log"
244
+ return session_dir / filename
245
+
246
+
247
+ def _refresh_latest_symlink(target: Path) -> None:
248
+ """Point the debug.log symlink at the latest session file."""
249
+
250
+ latest = const.DEFAULT_DEBUG_LOG_FILE
251
+ try:
252
+ latest.unlink(missing_ok=True)
253
+ latest.symlink_to(target)
254
+ except OSError:
255
+ # Non-blocking best-effort; logging should still proceed
256
+ return
257
+
258
+
259
+ def _prune_old_logs(log_root: Path, keep_days: int, max_total_bytes: int) -> None:
260
+ """Remove logs older than keep_days or when exceeding max_total_bytes."""
261
+
262
+ if not log_root.exists():
263
+ return
264
+
265
+ cutoff = datetime.now() - timedelta(days=keep_days)
266
+ files: list[Path] = [p for p in log_root.rglob("*") if p.is_file() and not p.is_symlink()]
267
+
268
+ # Remove by age
269
+ for path in files:
270
+ try:
271
+ mtime = datetime.fromtimestamp(path.stat().st_mtime)
272
+ except OSError:
273
+ continue
274
+ if mtime < cutoff:
275
+ _trash_path(path)
276
+
277
+ # Recompute remaining files and sizes
278
+ remaining: list[tuple[Path, float, int]] = []
279
+ total_size = 0
280
+ for path in log_root.rglob("*"):
281
+ if not path.is_file() or path.is_symlink():
282
+ continue
283
+ try:
284
+ stat = path.stat()
285
+ except OSError:
286
+ continue
287
+ remaining.append((path, stat.st_mtime, stat.st_size))
288
+ total_size += stat.st_size
289
+
290
+ if total_size <= max_total_bytes:
291
+ return
292
+
293
+ remaining.sort(key=lambda item: item[1])
294
+ for path, _, size in remaining:
295
+ _trash_path(path)
296
+ total_size -= size
297
+ if total_size <= max_total_bytes:
298
+ break
299
+
300
+
301
+ def _trash_path(path: Path) -> None:
302
+ """Send a path to trash, falling back to unlink if trash is unavailable."""
303
+
304
+ try:
305
+ subprocess.run(["trash", str(path)], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
306
+ except FileNotFoundError:
307
+ path.unlink(missing_ok=True)
@@ -73,19 +73,14 @@ def create_exec_display(debug: bool = False, stream_json: bool = False) -> Displ
73
73
 
74
74
 
75
75
  __all__ = [
76
- # Abstract interfaces
76
+ "DebugEventDisplay",
77
77
  "DisplayABC",
78
+ "ExecDisplay",
78
79
  "InputProviderABC",
79
- # Display mode implementations
80
+ "PromptToolkitInput",
80
81
  "REPLDisplay",
81
- "ExecDisplay",
82
82
  "StreamJsonDisplay",
83
- "DebugEventDisplay",
84
- # Input implementations
85
- "PromptToolkitInput",
86
- # Factory functions
83
+ "TerminalNotifier",
87
84
  "create_default_display",
88
85
  "create_exec_display",
89
- # Supporting types
90
- "TerminalNotifier",
91
86
  ]
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Awaitable, Callable
3
4
  from enum import Enum
4
- from typing import Awaitable, Callable
5
5
 
6
6
 
7
7
  class Stage(Enum):
@@ -19,10 +19,12 @@ class StageManager:
19
19
  self,
20
20
  *,
21
21
  finish_assistant: Callable[[], Awaitable[None]],
22
+ finish_thinking: Callable[[], Awaitable[None]],
22
23
  on_enter_thinking: Callable[[], None],
23
24
  ):
24
25
  self._stage = Stage.WAITING
25
26
  self._finish_assistant = finish_assistant
27
+ self._finish_thinking = finish_thinking
26
28
  self._on_enter_thinking = on_enter_thinking
27
29
 
28
30
  @property
@@ -49,7 +51,8 @@ class StageManager:
49
51
  self._stage = Stage.WAITING
50
52
 
51
53
  async def _leave_current_stage(self) -> None:
52
- if self._stage == Stage.ASSISTANT:
54
+ if self._stage == Stage.THINKING:
55
+ await self._finish_thinking()
56
+ elif self._stage == Stage.ASSISTANT:
53
57
  await self.finish_assistant()
54
- elif self._stage != Stage.WAITING:
55
- self._stage = Stage.WAITING
58
+ self._stage = Stage.WAITING
@@ -1,3 +1,4 @@
1
+ import os
1
2
  from typing import override
2
3
 
3
4
  from klaude_code import const
@@ -10,7 +11,7 @@ class DebugEventDisplay(DisplayABC):
10
11
  def __init__(
11
12
  self,
12
13
  wrapped_display: DisplayABC | None = None,
13
- log_file: str = const.DEFAULT_DEBUG_LOG_FILE,
14
+ log_file: str | os.PathLike[str] = const.DEFAULT_DEBUG_LOG_FILE,
14
15
  ):
15
16
  self.wrapped_display = wrapped_display
16
17
  self.log_file = log_file
@@ -9,7 +9,7 @@ if TYPE_CHECKING:
9
9
  from klaude_code.core.agent import Agent
10
10
 
11
11
 
12
- def build_repl_status_snapshot(agent: "Agent | None", update_message: str | None) -> REPLStatusSnapshot:
12
+ def build_repl_status_snapshot(agent: Agent | None, update_message: str | None) -> REPLStatusSnapshot:
13
13
  """Build a status snapshot for the REPL bottom toolbar.
14
14
 
15
15
  Aggregates model name, context usage, and basic call counts from the
@@ -67,12 +67,12 @@ class _SlashCommandCompleter(Completer):
67
67
  ) -> Iterable[Completion]:
68
68
  # Only complete on first line
69
69
  if document.cursor_position_row != 0:
70
- return iter([])
70
+ return
71
71
 
72
72
  text_before = document.current_line_before_cursor
73
73
  m = self._SLASH_TOKEN_RE.search(text_before)
74
74
  if not m:
75
- return iter([])
75
+ return
76
76
 
77
77
  frag = m.group("frag")
78
78
  token_start = len(text_before) - len(f"/{frag}")
@@ -89,7 +89,7 @@ class _SlashCommandCompleter(Completer):
89
89
  matched.append((cmd_name, cmd_obj, hint))
90
90
 
91
91
  if not matched:
92
- return iter([])
92
+ return
93
93
 
94
94
  # Calculate max width for alignment
95
95
  # Find the longest command+hint length
@@ -133,10 +133,9 @@ class _ComboCompleter(Completer):
133
133
  complete_event, # type: ignore[override]
134
134
  ) -> Iterable[Completion]:
135
135
  # Try slash command completion first (only on first line)
136
- if document.cursor_position_row == 0:
137
- if self._slash_completer.is_slash_command_context(document):
138
- yield from self._slash_completer.get_completions(document, complete_event)
139
- return
136
+ if document.cursor_position_row == 0 and self._slash_completer.is_slash_command_context(document):
137
+ yield from self._slash_completer.get_completions(document, complete_event)
138
+ return
140
139
 
141
140
  # Fall back to @ file completion
142
141
  yield from self._at_completer.get_completions(document, complete_event)
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import contextlib
3
4
  from typing import override
4
5
 
5
6
  from klaude_code.protocol import events
@@ -53,8 +54,6 @@ class REPLDisplay(DisplayABC):
53
54
  async def stop(self) -> None:
54
55
  await self.event_handler.stop()
55
56
  # Ensure any active spinner is stopped so Rich restores the cursor.
56
- try:
57
+ # Spinner may already be stopped or not started; ignore.
58
+ with contextlib.suppress(Exception):
57
59
  self.renderer.spinner_stop()
58
- except Exception:
59
- # Spinner may already be stopped or not started; ignore.
60
- pass
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Awaitable, Callable
3
4
  from dataclasses import dataclass
4
- from typing import Awaitable, Callable
5
5
 
6
6
  from rich.text import Text
7
7
 
@@ -10,6 +10,7 @@ from klaude_code.protocol import events
10
10
  from klaude_code.ui.core.stage_manager import Stage, StageManager
11
11
  from klaude_code.ui.modes.repl.renderer import REPLRenderer
12
12
  from klaude_code.ui.rich.markdown import MarkdownStream
13
+ from klaude_code.ui.rich.theme import ThemeKey
13
14
  from klaude_code.ui.terminal.notifier import Notification, NotificationType, TerminalNotifier
14
15
  from klaude_code.ui.terminal.progress_bar import OSC94States, emit_osc94
15
16
  from klaude_code.ui.utils.debouncer import Debouncer
@@ -41,7 +42,7 @@ class StreamState:
41
42
  This design ensures buffer and mdstream are always in sync.
42
43
  """
43
44
 
44
- def __init__(self, interval: float, flush_handler: Callable[["StreamState"], Awaitable[None]]):
45
+ def __init__(self, interval: float, flush_handler: Callable[[StreamState], Awaitable[None]]):
45
46
  self._active: ActiveStream | None = None
46
47
  self._flush_handler = flush_handler
47
48
  self.debouncer = Debouncer(interval=interval, callback=self._debounced_flush)
@@ -199,10 +200,14 @@ class DisplayEventHandler:
199
200
  self.assistant_stream = StreamState(
200
201
  interval=1 / const.UI_REFRESH_RATE_FPS, flush_handler=self._flush_assistant_buffer
201
202
  )
203
+ self.thinking_stream = StreamState(
204
+ interval=1 / const.UI_REFRESH_RATE_FPS, flush_handler=self._flush_thinking_buffer
205
+ )
202
206
  self.spinner_status = SpinnerStatusState()
203
207
 
204
208
  self.stage_manager = StageManager(
205
209
  finish_assistant=self._finish_assistant_stream,
210
+ finish_thinking=self._finish_thinking_stream,
206
211
  on_enter_thinking=self._print_thinking_prefix,
207
212
  )
208
213
 
@@ -222,6 +227,8 @@ class DisplayEventHandler:
222
227
  self._on_turn_start(e)
223
228
  case events.ThinkingEvent() as e:
224
229
  await self._on_thinking(e)
230
+ case events.ThinkingDeltaEvent() as e:
231
+ await self._on_thinking_delta(e)
225
232
  case events.AssistantMessageDeltaEvent() as e:
226
233
  await self._on_assistant_delta(e)
227
234
  case events.AssistantMessageEvent() as e:
@@ -252,6 +259,8 @@ class DisplayEventHandler:
252
259
  async def stop(self) -> None:
253
260
  await self.assistant_stream.debouncer.flush()
254
261
  self.assistant_stream.debouncer.cancel()
262
+ await self.thinking_stream.debouncer.flush()
263
+ self.thinking_stream.debouncer.cancel()
255
264
 
256
265
  # ─────────────────────────────────────────────────────────────────────────────
257
266
  # Private event handlers
@@ -285,8 +294,41 @@ class DisplayEventHandler:
285
294
  async def _on_thinking(self, event: events.ThinkingEvent) -> None:
286
295
  if self.renderer.is_sub_agent_session(event.session_id):
287
296
  return
297
+ # If streaming was active, finalize it
298
+ if self.thinking_stream.is_active:
299
+ await self._finish_thinking_stream()
300
+ else:
301
+ # Non-streaming path (history replay or models without delta support)
302
+ await self.stage_manager.enter_thinking_stage()
303
+ self.renderer.display_thinking(event.content)
304
+
305
+ async def _on_thinking_delta(self, event: events.ThinkingDeltaEvent) -> None:
306
+ if self.renderer.is_sub_agent_session(event.session_id):
307
+ return
308
+
309
+ first_delta = not self.thinking_stream.is_active
310
+ if first_delta:
311
+ self.renderer.console.push_theme(self.renderer.themes.thinking_markdown_theme)
312
+ mdstream = MarkdownStream(
313
+ mdargs={
314
+ "code_theme": self.renderer.themes.code_theme,
315
+ "style": self.renderer.console.get_style(ThemeKey.THINKING),
316
+ },
317
+ theme=self.renderer.themes.thinking_markdown_theme,
318
+ console=self.renderer.console,
319
+ spinner=self.renderer.spinner_renderable(),
320
+ indent=2,
321
+ )
322
+ self.thinking_stream.start(mdstream)
323
+ self.renderer.spinner_stop()
324
+
325
+ self.thinking_stream.append(event.content)
326
+
327
+ if first_delta and self.thinking_stream.mdstream is not None:
328
+ self.thinking_stream.mdstream.update(self.thinking_stream.buffer)
329
+
288
330
  await self.stage_manager.enter_thinking_stage()
289
- self.renderer.display_thinking(event.content)
331
+ self.thinking_stream.debouncer.schedule()
290
332
 
291
333
  async def _on_assistant_delta(self, event: events.AssistantMessageDeltaEvent) -> None:
292
334
  if self.renderer.is_sub_agent_session(event.session_id):
@@ -419,6 +461,22 @@ class DisplayEventHandler:
419
461
  assert mdstream is not None
420
462
  mdstream.update(state.buffer)
421
463
 
464
+ async def _flush_thinking_buffer(self, state: StreamState) -> None:
465
+ if state.is_active:
466
+ mdstream = state.mdstream
467
+ assert mdstream is not None
468
+ mdstream.update(state.buffer)
469
+
470
+ async def _finish_thinking_stream(self) -> None:
471
+ if self.thinking_stream.is_active:
472
+ self.thinking_stream.debouncer.cancel()
473
+ mdstream = self.thinking_stream.mdstream
474
+ assert mdstream is not None
475
+ mdstream.update(self.thinking_stream.buffer, final=True)
476
+ self.thinking_stream.finish()
477
+ self.renderer.console.pop_theme()
478
+ self.renderer.spinner_start()
479
+
422
480
  def _maybe_notify_task_finish(self, event: events.TaskFinishEvent) -> None:
423
481
  if self.notifier is None:
424
482
  return
@@ -453,10 +511,10 @@ class DisplayEventHandler:
453
511
  if len(todo.content) > 0:
454
512
  status_text = todo.content
455
513
  status_text = status_text.replace("\n", "")
456
- return self._truncate_status_text(status_text, max_length=100)
514
+ return self._truncate_status_text(status_text, max_length=50)
457
515
 
458
516
  def _truncate_status_text(self, text: str, max_length: int) -> str:
459
517
  if len(text) <= max_length:
460
518
  return text
461
519
  truncated = text[:max_length]
462
- return truncated + "..."
520
+ return truncated + ""
@@ -6,6 +6,7 @@ with dependencies injected to avoid circular imports.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import contextlib
9
10
  import re
10
11
  from collections.abc import Callable
11
12
  from typing import cast
@@ -35,10 +36,8 @@ def create_key_bindings(
35
36
  """Paste image from clipboard as [Image #N]."""
36
37
  tag = capture_clipboard_tag()
37
38
  if tag:
38
- try:
39
+ with contextlib.suppress(Exception):
39
40
  event.current_buffer.insert_text(tag) # pyright: ignore[reportUnknownMemberType]
40
- except Exception:
41
- pass
42
41
 
43
42
  @kb.add("enter")
44
43
  def _(event): # type: ignore