klaude-code 1.9.0__py3-none-any.whl → 2.0.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 (132) hide show
  1. klaude_code/auth/base.py +2 -6
  2. klaude_code/cli/auth_cmd.py +4 -4
  3. klaude_code/cli/cost_cmd.py +1 -1
  4. klaude_code/cli/list_model.py +1 -1
  5. klaude_code/cli/main.py +1 -1
  6. klaude_code/cli/runtime.py +7 -5
  7. klaude_code/cli/self_update.py +1 -1
  8. klaude_code/cli/session_cmd.py +1 -1
  9. klaude_code/command/clear_cmd.py +6 -2
  10. klaude_code/command/command_abc.py +2 -2
  11. klaude_code/command/debug_cmd.py +4 -4
  12. klaude_code/command/export_cmd.py +2 -2
  13. klaude_code/command/export_online_cmd.py +12 -12
  14. klaude_code/command/fork_session_cmd.py +29 -23
  15. klaude_code/command/help_cmd.py +4 -4
  16. klaude_code/command/model_cmd.py +4 -4
  17. klaude_code/command/model_select.py +1 -1
  18. klaude_code/command/prompt-commit.md +11 -2
  19. klaude_code/command/prompt_command.py +3 -3
  20. klaude_code/command/refresh_cmd.py +2 -2
  21. klaude_code/command/registry.py +7 -5
  22. klaude_code/command/release_notes_cmd.py +4 -4
  23. klaude_code/command/resume_cmd.py +15 -11
  24. klaude_code/command/status_cmd.py +4 -4
  25. klaude_code/command/terminal_setup_cmd.py +8 -8
  26. klaude_code/command/thinking_cmd.py +4 -4
  27. klaude_code/config/assets/builtin_config.yaml +20 -0
  28. klaude_code/config/builtin_config.py +16 -5
  29. klaude_code/config/config.py +7 -2
  30. klaude_code/const.py +147 -91
  31. klaude_code/core/agent.py +3 -12
  32. klaude_code/core/executor.py +18 -39
  33. klaude_code/core/manager/sub_agent_manager.py +71 -7
  34. klaude_code/core/prompts/prompt-sub-agent-image-gen.md +1 -0
  35. klaude_code/core/prompts/prompt-sub-agent-web.md +27 -1
  36. klaude_code/core/reminders.py +88 -69
  37. klaude_code/core/task.py +44 -45
  38. klaude_code/core/tool/file/apply_patch_tool.py +9 -9
  39. klaude_code/core/tool/file/diff_builder.py +3 -5
  40. klaude_code/core/tool/file/edit_tool.py +23 -23
  41. klaude_code/core/tool/file/move_tool.py +43 -43
  42. klaude_code/core/tool/file/read_tool.py +44 -39
  43. klaude_code/core/tool/file/write_tool.py +14 -14
  44. klaude_code/core/tool/report_back_tool.py +4 -4
  45. klaude_code/core/tool/shell/bash_tool.py +23 -23
  46. klaude_code/core/tool/skill/skill_tool.py +7 -7
  47. klaude_code/core/tool/sub_agent_tool.py +38 -9
  48. klaude_code/core/tool/todo/todo_write_tool.py +9 -10
  49. klaude_code/core/tool/todo/update_plan_tool.py +6 -6
  50. klaude_code/core/tool/tool_abc.py +2 -2
  51. klaude_code/core/tool/tool_context.py +27 -0
  52. klaude_code/core/tool/tool_runner.py +88 -42
  53. klaude_code/core/tool/truncation.py +38 -20
  54. klaude_code/core/tool/web/mermaid_tool.py +6 -7
  55. klaude_code/core/tool/web/web_fetch_tool.py +68 -30
  56. klaude_code/core/tool/web/web_search_tool.py +15 -17
  57. klaude_code/core/turn.py +120 -73
  58. klaude_code/llm/anthropic/client.py +79 -44
  59. klaude_code/llm/anthropic/input.py +116 -108
  60. klaude_code/llm/bedrock/client.py +8 -5
  61. klaude_code/llm/claude/client.py +18 -8
  62. klaude_code/llm/client.py +4 -3
  63. klaude_code/llm/codex/client.py +15 -9
  64. klaude_code/llm/google/client.py +122 -60
  65. klaude_code/llm/google/input.py +94 -108
  66. klaude_code/llm/image.py +123 -0
  67. klaude_code/llm/input_common.py +136 -189
  68. klaude_code/llm/openai_compatible/client.py +17 -7
  69. klaude_code/llm/openai_compatible/input.py +36 -66
  70. klaude_code/llm/openai_compatible/stream.py +119 -67
  71. klaude_code/llm/openai_compatible/tool_call_accumulator.py +23 -11
  72. klaude_code/llm/openrouter/client.py +34 -9
  73. klaude_code/llm/openrouter/input.py +63 -64
  74. klaude_code/llm/openrouter/reasoning.py +22 -24
  75. klaude_code/llm/registry.py +20 -17
  76. klaude_code/llm/responses/client.py +107 -45
  77. klaude_code/llm/responses/input.py +115 -98
  78. klaude_code/llm/usage.py +52 -25
  79. klaude_code/protocol/__init__.py +1 -0
  80. klaude_code/protocol/events.py +16 -12
  81. klaude_code/protocol/llm_param.py +20 -2
  82. klaude_code/protocol/message.py +250 -0
  83. klaude_code/protocol/model.py +95 -285
  84. klaude_code/protocol/op.py +2 -15
  85. klaude_code/protocol/op_handler.py +0 -5
  86. klaude_code/protocol/sub_agent/__init__.py +1 -0
  87. klaude_code/protocol/sub_agent/explore.py +10 -0
  88. klaude_code/protocol/sub_agent/image_gen.py +119 -0
  89. klaude_code/protocol/sub_agent/task.py +10 -0
  90. klaude_code/protocol/sub_agent/web.py +10 -0
  91. klaude_code/session/codec.py +6 -6
  92. klaude_code/session/export.py +261 -62
  93. klaude_code/session/selector.py +7 -24
  94. klaude_code/session/session.py +126 -54
  95. klaude_code/session/store.py +5 -32
  96. klaude_code/session/templates/export_session.html +1 -1
  97. klaude_code/session/templates/mermaid_viewer.html +1 -1
  98. klaude_code/trace/log.py +11 -6
  99. klaude_code/ui/core/input.py +1 -1
  100. klaude_code/ui/core/stage_manager.py +1 -8
  101. klaude_code/ui/modes/debug/display.py +2 -2
  102. klaude_code/ui/modes/repl/clipboard.py +2 -2
  103. klaude_code/ui/modes/repl/completers.py +18 -10
  104. klaude_code/ui/modes/repl/event_handler.py +138 -132
  105. klaude_code/ui/modes/repl/input_prompt_toolkit.py +1 -1
  106. klaude_code/ui/modes/repl/key_bindings.py +136 -2
  107. klaude_code/ui/modes/repl/renderer.py +107 -15
  108. klaude_code/ui/renderers/assistant.py +2 -2
  109. klaude_code/ui/renderers/bash_syntax.py +36 -4
  110. klaude_code/ui/renderers/common.py +70 -10
  111. klaude_code/ui/renderers/developer.py +7 -6
  112. klaude_code/ui/renderers/diffs.py +11 -11
  113. klaude_code/ui/renderers/mermaid_viewer.py +49 -2
  114. klaude_code/ui/renderers/metadata.py +33 -5
  115. klaude_code/ui/renderers/sub_agent.py +57 -16
  116. klaude_code/ui/renderers/thinking.py +37 -2
  117. klaude_code/ui/renderers/tools.py +188 -178
  118. klaude_code/ui/rich/live.py +3 -1
  119. klaude_code/ui/rich/markdown.py +39 -7
  120. klaude_code/ui/rich/quote.py +76 -1
  121. klaude_code/ui/rich/status.py +14 -8
  122. klaude_code/ui/rich/theme.py +20 -14
  123. klaude_code/ui/terminal/image.py +34 -0
  124. klaude_code/ui/terminal/notifier.py +2 -1
  125. klaude_code/ui/terminal/progress_bar.py +4 -4
  126. klaude_code/ui/terminal/selector.py +22 -4
  127. klaude_code/ui/utils/common.py +11 -2
  128. {klaude_code-1.9.0.dist-info → klaude_code-2.0.1.dist-info}/METADATA +4 -2
  129. klaude_code-2.0.1.dist-info/RECORD +229 -0
  130. klaude_code-1.9.0.dist-info/RECORD +0 -224
  131. {klaude_code-1.9.0.dist-info → klaude_code-2.0.1.dist-info}/WHEEL +0 -0
  132. {klaude_code-1.9.0.dist-info → klaude_code-2.0.1.dist-info}/entry_points.txt +0 -0
@@ -1,16 +1,22 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Callable
3
4
  from dataclasses import dataclass
4
5
 
5
6
  from rich.rule import Rule
6
7
  from rich.text import Text
7
8
 
8
- from klaude_code import const
9
+ from klaude_code.const import MARKDOWN_LEFT_MARGIN, MARKDOWN_STREAM_LIVE_REPAINT_ENABLED, STATUS_DEFAULT_TEXT
9
10
  from klaude_code.protocol import events
10
11
  from klaude_code.ui.core.stage_manager import Stage, StageManager
11
12
  from klaude_code.ui.modes.repl.renderer import REPLRenderer
12
13
  from klaude_code.ui.renderers.assistant import ASSISTANT_MESSAGE_MARK
13
- from klaude_code.ui.renderers.thinking import THINKING_MESSAGE_MARK, normalize_thinking_content
14
+ from klaude_code.ui.renderers.thinking import (
15
+ THINKING_MESSAGE_MARK,
16
+ extract_last_bold_header,
17
+ normalize_thinking_content,
18
+ )
19
+ from klaude_code.ui.renderers.tools import get_tool_active_form
14
20
  from klaude_code.ui.rich import status as r_status
15
21
  from klaude_code.ui.rich.markdown import MarkdownStream, ThinkingMarkdown
16
22
  from klaude_code.ui.rich.theme import ThemeKey
@@ -18,39 +24,24 @@ from klaude_code.ui.terminal.notifier import Notification, NotificationType, Ter
18
24
  from klaude_code.ui.terminal.progress_bar import OSC94States, emit_osc94
19
25
 
20
26
 
21
- def extract_last_bold_header(text: str) -> str | None:
22
- """Extract the latest complete bold header ("**...**") from text.
23
-
24
- We treat a bold segment as a "header" only if it appears at the beginning
25
- of a line (ignoring leading whitespace). This avoids picking up incidental
26
- emphasis inside paragraphs.
27
-
28
- Returns None if no complete bold segment is available yet.
29
- """
30
-
31
- last: str | None = None
32
- i = 0
33
- while True:
34
- start = text.find("**", i)
35
- if start < 0:
36
- break
37
-
38
- line_start = text.rfind("\n", 0, start) + 1
39
- if text[line_start:start].strip():
40
- i = start + 2
41
- continue
42
-
43
- end = text.find("**", start + 2)
44
- if end < 0:
45
- break
27
+ @dataclass
28
+ class SubAgentThinkingHeaderState:
29
+ buffer: str = ""
30
+ last_header: str | None = None
46
31
 
47
- inner = " ".join(text[start + 2 : end].split())
48
- if inner and "\n" not in inner:
49
- last = inner
32
+ def append_and_extract_new_header(self, content: str) -> str | None:
33
+ self.buffer += content
50
34
 
51
- i = end + 2
35
+ # Sub-agent thinking does not need full streaming; keep a bounded tail.
36
+ max_chars = 8192
37
+ if len(self.buffer) > max_chars:
38
+ self.buffer = self.buffer[-max_chars:]
52
39
 
53
- return last
40
+ header = extract_last_bold_header(normalize_thinking_content(self.buffer))
41
+ if header and header != self.last_header:
42
+ self.last_header = header
43
+ return header
44
+ return None
54
45
 
55
46
 
56
47
  @dataclass
@@ -107,6 +98,31 @@ class StreamState:
107
98
  """End the current streaming session."""
108
99
  self._active = None
109
100
 
101
+ def render(self, *, transform: Callable[[str], str] | None = None, final: bool = False) -> bool:
102
+ """Render the current buffer to the markdown stream.
103
+
104
+ Returns:
105
+ bool: True if an active stream was rendered.
106
+ """
107
+
108
+ if self._active is None:
109
+ return False
110
+
111
+ text = self._active.buffer
112
+ if transform is not None:
113
+ text = transform(text)
114
+ self._active.mdstream.update(text, final=final)
115
+
116
+ if final:
117
+ self.finish()
118
+
119
+ return True
120
+
121
+ def finalize(self, *, transform: Callable[[str], str] | None = None) -> bool:
122
+ """Finalize rendering and end the current streaming session."""
123
+
124
+ return self.render(transform=transform, final=True)
125
+
110
126
 
111
127
  class ActivityState:
112
128
  """Represents the current activity state for spinner display.
@@ -185,7 +201,7 @@ class SpinnerStatusState:
185
201
  - context_percent: Context usage percentage, updated during task execution
186
202
 
187
203
  Display logic:
188
- - If activity: show base + activity (if base exists) or activity + "..."
204
+ - If activity: show base + activity (if base exists) or activity + ""
189
205
  - Elif base_status: show base_status
190
206
  - Else: show "Thinking …"
191
207
  - Context percent is appended at the end if available
@@ -260,7 +276,7 @@ class SpinnerStatusState:
260
276
  activity_text.append(" …")
261
277
  result = activity_text
262
278
  else:
263
- result = Text(const.STATUS_DEFAULT_TEXT, style=ThemeKey.STATUS_TEXT)
279
+ result = Text(STATUS_DEFAULT_TEXT, style=ThemeKey.STATUS_TEXT)
264
280
 
265
281
  return result
266
282
 
@@ -295,6 +311,7 @@ class DisplayEventHandler:
295
311
  self.notifier = notifier
296
312
  self.assistant_stream = StreamState()
297
313
  self.thinking_stream = StreamState()
314
+ self._sub_agent_thinking_headers: dict[str, SubAgentThinkingHeaderState] = {}
298
315
  self.spinner_status = SpinnerStatusState()
299
316
 
300
317
  self.stage_manager = StageManager(
@@ -302,6 +319,31 @@ class DisplayEventHandler:
302
319
  finish_thinking=self._finish_thinking_stream,
303
320
  )
304
321
 
322
+ def _new_thinking_mdstream(self) -> MarkdownStream:
323
+ return MarkdownStream(
324
+ mdargs={
325
+ "code_theme": self.renderer.themes.code_theme,
326
+ "style": ThemeKey.THINKING,
327
+ },
328
+ theme=self.renderer.themes.thinking_markdown_theme,
329
+ console=self.renderer.console,
330
+ live_sink=self.renderer.set_stream_renderable if MARKDOWN_STREAM_LIVE_REPAINT_ENABLED else None,
331
+ mark=THINKING_MESSAGE_MARK,
332
+ mark_style=ThemeKey.THINKING,
333
+ left_margin=MARKDOWN_LEFT_MARGIN,
334
+ markdown_class=ThinkingMarkdown,
335
+ )
336
+
337
+ def _new_assistant_mdstream(self) -> MarkdownStream:
338
+ return MarkdownStream(
339
+ mdargs={"code_theme": self.renderer.themes.code_theme},
340
+ theme=self.renderer.themes.markdown_theme,
341
+ console=self.renderer.console,
342
+ live_sink=self.renderer.set_stream_renderable if MARKDOWN_STREAM_LIVE_REPAINT_ENABLED else None,
343
+ mark=ASSISTANT_MESSAGE_MARK,
344
+ left_margin=MARKDOWN_LEFT_MARGIN,
345
+ )
346
+
305
347
  async def consume_event(self, event: events.Event) -> None:
306
348
  match event:
307
349
  case events.ReplayHistoryEvent() as e:
@@ -316,16 +358,16 @@ class DisplayEventHandler:
316
358
  self._on_developer_message(e)
317
359
  case events.TurnStartEvent() as e:
318
360
  self._on_turn_start(e)
319
- case events.ThinkingEvent() as e:
320
- await self._on_thinking(e)
321
361
  case events.ThinkingDeltaEvent() as e:
322
362
  await self._on_thinking_delta(e)
323
- case events.AssistantMessageDeltaEvent() as e:
363
+ case events.AssistantTextDeltaEvent() as e:
324
364
  await self._on_assistant_delta(e)
365
+ case events.AssistantImageDeltaEvent() as e:
366
+ await self._on_assistant_image_delta(e)
325
367
  case events.AssistantMessageEvent() as e:
326
368
  await self._on_assistant_message(e)
327
369
  case events.TurnToolCallStartEvent() as e:
328
- self._on_tool_call_start(e)
370
+ await self._on_tool_call_start(e)
329
371
  case events.ToolCallEvent() as e:
330
372
  await self._on_tool_call(e)
331
373
  case events.ToolResultEvent() as e:
@@ -350,8 +392,8 @@ class DisplayEventHandler:
350
392
  await self._on_end(e)
351
393
 
352
394
  async def stop(self) -> None:
353
- await self._flush_assistant_buffer(self.assistant_stream)
354
- await self._flush_thinking_buffer(self.thinking_stream)
395
+ self._flush_assistant_buffer()
396
+ self._flush_thinking_buffer()
355
397
 
356
398
  # ─────────────────────────────────────────────────────────────────────────────
357
399
  # Private event handlers
@@ -370,6 +412,8 @@ class DisplayEventHandler:
370
412
  def _on_task_start(self, event: events.TaskStartEvent) -> None:
371
413
  if event.sub_agent_state is None:
372
414
  r_status.set_task_start()
415
+ else:
416
+ self._sub_agent_thinking_headers[event.session_id] = SubAgentThinkingHeaderState()
373
417
  self.renderer.spinner_start()
374
418
  self.renderer.display_task_start(event)
375
419
  emit_osc94(OSC94States.INDETERMINATE)
@@ -385,41 +429,20 @@ class DisplayEventHandler:
385
429
  self.spinner_status.set_reasoning_status(None)
386
430
  self._update_spinner()
387
431
 
388
- async def _on_thinking(self, event: events.ThinkingEvent) -> None:
389
- if self.renderer.is_sub_agent_session(event.session_id):
390
- return
391
- # If streaming was active, finalize it
392
- if self.thinking_stream.is_active:
393
- await self._finish_thinking_stream()
394
- else:
395
- # Non-streaming path (history replay or models without delta support)
396
- reasoning_status = extract_last_bold_header(normalize_thinking_content(event.content))
397
- if reasoning_status:
398
- self.spinner_status.set_reasoning_status(reasoning_status)
399
- self._update_spinner()
400
- await self.stage_manager.enter_thinking_stage()
401
- self.renderer.display_thinking(event.content)
402
-
403
432
  async def _on_thinking_delta(self, event: events.ThinkingDeltaEvent) -> None:
404
433
  if self.renderer.is_sub_agent_session(event.session_id):
434
+ if not self.renderer.should_display_sub_agent_thinking_header(event.session_id):
435
+ return
436
+ state = self._sub_agent_thinking_headers.setdefault(event.session_id, SubAgentThinkingHeaderState())
437
+ header = state.append_and_extract_new_header(event.content)
438
+ if header:
439
+ with self.renderer.session_print_context(event.session_id):
440
+ self.renderer.display_thinking_header(header)
405
441
  return
406
442
 
407
443
  first_delta = not self.thinking_stream.is_active
408
444
  if first_delta:
409
- mdstream = MarkdownStream(
410
- mdargs={
411
- "code_theme": self.renderer.themes.code_theme,
412
- "style": ThemeKey.THINKING,
413
- },
414
- theme=self.renderer.themes.thinking_markdown_theme,
415
- console=self.renderer.console,
416
- live_sink=self.renderer.set_stream_renderable if const.MARKDOWN_STREAM_LIVE_REPAINT_ENABLED else None,
417
- mark=THINKING_MESSAGE_MARK,
418
- mark_style=ThemeKey.THINKING,
419
- left_margin=const.MARKDOWN_LEFT_MARGIN,
420
- markdown_class=ThinkingMarkdown,
421
- )
422
- self.thinking_stream.start(mdstream)
445
+ self.thinking_stream.start(self._new_thinking_mdstream())
423
446
 
424
447
  self.thinking_stream.append(event.content)
425
448
 
@@ -428,62 +451,52 @@ class DisplayEventHandler:
428
451
  self.spinner_status.set_reasoning_status(reasoning_status)
429
452
  self._update_spinner()
430
453
 
431
- if first_delta and self.thinking_stream.mdstream is not None:
432
- self.thinking_stream.mdstream.update(normalize_thinking_content(self.thinking_stream.buffer))
454
+ if first_delta:
455
+ self.thinking_stream.render(transform=normalize_thinking_content)
433
456
 
434
457
  await self.stage_manager.enter_thinking_stage()
435
- await self._flush_thinking_buffer(self.thinking_stream)
458
+ self._flush_thinking_buffer()
436
459
 
437
- async def _on_assistant_delta(self, event: events.AssistantMessageDeltaEvent) -> None:
460
+ async def _on_assistant_delta(self, event: events.AssistantTextDeltaEvent) -> None:
438
461
  if self.renderer.is_sub_agent_session(event.session_id):
439
462
  self.spinner_status.set_composing(True)
440
463
  self._update_spinner()
441
464
  return
465
+
442
466
  if len(event.content.strip()) == 0 and self.stage_manager.current_stage != Stage.ASSISTANT:
467
+ await self.stage_manager.transition_to(Stage.WAITING)
443
468
  return
469
+
470
+ await self.stage_manager.transition_to(Stage.ASSISTANT)
444
471
  first_delta = not self.assistant_stream.is_active
445
472
  if first_delta:
446
473
  self.spinner_status.set_composing(True)
447
474
  self.spinner_status.clear_tool_calls()
448
475
  self._update_spinner()
449
- mdstream = MarkdownStream(
450
- mdargs={"code_theme": self.renderer.themes.code_theme},
451
- theme=self.renderer.themes.markdown_theme,
452
- console=self.renderer.console,
453
- live_sink=self.renderer.set_stream_renderable if const.MARKDOWN_STREAM_LIVE_REPAINT_ENABLED else None,
454
- mark=ASSISTANT_MESSAGE_MARK,
455
- left_margin=const.MARKDOWN_LEFT_MARGIN,
456
- )
457
- self.assistant_stream.start(mdstream)
476
+ self.assistant_stream.start(self._new_assistant_mdstream())
458
477
  self.assistant_stream.append(event.content)
459
478
  self.spinner_status.set_buffer_length(len(self.assistant_stream.buffer))
460
479
  if not first_delta:
461
480
  self._update_spinner()
462
- if first_delta and self.assistant_stream.mdstream is not None:
463
- self.assistant_stream.mdstream.update(self.assistant_stream.buffer)
464
- await self.stage_manager.transition_to(Stage.ASSISTANT)
465
- await self._flush_assistant_buffer(self.assistant_stream)
481
+ if first_delta:
482
+ self.assistant_stream.render()
483
+ self._flush_assistant_buffer()
466
484
 
467
485
  async def _on_assistant_message(self, event: events.AssistantMessageEvent) -> None:
468
486
  if self.renderer.is_sub_agent_session(event.session_id):
469
487
  return
470
- await self.stage_manager.transition_to(Stage.ASSISTANT)
471
- if self.assistant_stream.is_active:
472
- mdstream = self.assistant_stream.mdstream
473
- assert mdstream is not None
474
- mdstream.update(event.content.strip(), final=True)
475
- else:
476
- self.renderer.display_assistant_message(event.content)
477
- self.assistant_stream.finish()
488
+
489
+ await self.stage_manager.transition_to(Stage.WAITING)
478
490
  self.spinner_status.set_composing(False)
479
491
  self._update_spinner()
480
- await self.stage_manager.transition_to(Stage.WAITING)
481
- self.renderer.print()
482
492
  self.renderer.spinner_start()
483
493
 
484
- def _on_tool_call_start(self, event: events.TurnToolCallStartEvent) -> None:
485
- from klaude_code.ui.renderers.tools import get_tool_active_form
494
+ async def _on_assistant_image_delta(self, event: events.AssistantImageDeltaEvent) -> None:
495
+ await self.stage_manager.transition_to(Stage.ASSISTANT)
496
+ self.renderer.display_image(event.file_path)
486
497
 
498
+ async def _on_tool_call_start(self, event: events.TurnToolCallStartEvent) -> None:
499
+ self._flush_assistant_buffer()
487
500
  self.spinner_status.set_composing(False)
488
501
  self.spinner_status.add_tool_call(get_tool_active_form(event.tool_name))
489
502
  self._update_spinner()
@@ -494,18 +507,18 @@ class DisplayEventHandler:
494
507
  self.renderer.display_tool_call(event)
495
508
 
496
509
  async def _on_tool_result(self, event: events.ToolResultEvent) -> None:
497
- if self.renderer.is_sub_agent_session(event.session_id) and event.status == "success":
510
+ is_sub_agent = self.renderer.is_sub_agent_session(event.session_id)
511
+ if is_sub_agent and event.status == "success":
498
512
  return
499
513
  await self.stage_manager.transition_to(Stage.TOOL_RESULT)
500
514
  with self.renderer.session_print_context(event.session_id):
501
- self.renderer.display_tool_call_result(event)
515
+ self.renderer.display_tool_call_result(event, is_sub_agent=is_sub_agent)
502
516
 
503
517
  def _on_task_metadata(self, event: events.TaskMetadataEvent) -> None:
504
518
  self.renderer.display_task_metadata(event)
505
519
 
506
520
  def _on_todo_change(self, event: events.TodoChangeEvent) -> None:
507
- active_form_status_text = self._extract_active_form_text(event)
508
- self.spinner_status.set_todo_status(active_form_status_text if active_form_status_text else None)
521
+ self.spinner_status.set_todo_status(self._extract_active_form_text(event))
509
522
  # Clear tool calls when todo changes, as the tool execution has advanced
510
523
  self.spinner_status.clear_for_new_turn()
511
524
  self._update_spinner()
@@ -525,6 +538,8 @@ class DisplayEventHandler:
525
538
  self.renderer.spinner_stop()
526
539
  self.renderer.console.print(Rule(characters="─", style=ThemeKey.LINES))
527
540
  emit_tmux_signal() # Signal test harness if KLAUDE_TEST_SIGNAL is set
541
+ else:
542
+ self._sub_agent_thinking_headers.pop(event.session_id, None)
528
543
  await self.stage_manager.transition_to(Stage.WAITING)
529
544
  self._maybe_notify_task_finish(event)
530
545
 
@@ -555,13 +570,6 @@ class DisplayEventHandler:
555
570
  # Private helper methods
556
571
  # ─────────────────────────────────────────────────────────────────────────────
557
572
 
558
- async def _finish_assistant_stream(self) -> None:
559
- if self.assistant_stream.is_active:
560
- mdstream = self.assistant_stream.mdstream
561
- assert mdstream is not None
562
- mdstream.update(self.assistant_stream.buffer, final=True)
563
- self.assistant_stream.finish()
564
-
565
573
  def _update_spinner(self) -> None:
566
574
  """Update spinner text from current status state."""
567
575
  status_text = self.spinner_status.get_status()
@@ -571,27 +579,23 @@ class DisplayEventHandler:
571
579
  right_text,
572
580
  )
573
581
 
574
- async def _flush_assistant_buffer(self, state: StreamState) -> None:
575
- if state.is_active:
576
- mdstream = state.mdstream
577
- assert mdstream is not None
578
- mdstream.update(state.buffer)
582
+ def _flush_thinking_buffer(self) -> None:
583
+ self.thinking_stream.render(transform=normalize_thinking_content)
579
584
 
580
- async def _flush_thinking_buffer(self, state: StreamState) -> None:
581
- if state.is_active:
582
- mdstream = state.mdstream
583
- assert mdstream is not None
584
- mdstream.update(normalize_thinking_content(state.buffer))
585
+ def _flush_assistant_buffer(self) -> None:
586
+ self.assistant_stream.render()
585
587
 
586
588
  async def _finish_thinking_stream(self) -> None:
587
- if self.thinking_stream.is_active:
588
- mdstream = self.thinking_stream.mdstream
589
- assert mdstream is not None
590
- mdstream.update(normalize_thinking_content(self.thinking_stream.buffer), final=True)
591
- self.thinking_stream.finish()
589
+ finalized = self.thinking_stream.finalize(transform=normalize_thinking_content)
590
+ if finalized:
592
591
  self.renderer.print()
593
592
  self.renderer.spinner_start()
594
593
 
594
+ async def _finish_assistant_stream(self) -> None:
595
+ finalized = self.assistant_stream.finalize()
596
+ if finalized:
597
+ self.renderer.print()
598
+
595
599
  def _maybe_notify_task_finish(self, event: events.TaskFinishEvent) -> None:
596
600
  if self.notifier is None:
597
601
  return
@@ -617,12 +621,14 @@ class DisplayEventHandler:
617
621
  return squashed[:197] + "…"
618
622
  return squashed
619
623
 
620
- def _extract_active_form_text(self, todo_event: events.TodoChangeEvent) -> str:
621
- status_text = ""
624
+ def _extract_active_form_text(self, todo_event: events.TodoChangeEvent) -> str | None:
625
+ status_text: str | None = None
622
626
  for todo in todo_event.todos:
623
- if todo.status == "in_progress":
624
- if len(todo.active_form) > 0:
625
- status_text = todo.active_form
626
- if len(todo.content) > 0:
627
- status_text = todo.content
628
- return status_text.replace("\n", " ").strip()
627
+ if todo.status == "in_progress" and len(todo.content) > 0:
628
+ status_text = todo.content
629
+
630
+ if status_text is None:
631
+ return None
632
+
633
+ normalized = status_text.replace("\n", " ").strip()
634
+ return normalized if normalized else None
@@ -34,7 +34,7 @@ from klaude_code.config.thinking import (
34
34
  )
35
35
  from klaude_code.protocol import llm_param
36
36
  from klaude_code.protocol.commands import CommandInfo
37
- from klaude_code.protocol.model import UserInputPayload
37
+ from klaude_code.protocol.message import UserInputPayload
38
38
  from klaude_code.ui.core.input import InputProviderABC
39
39
  from klaude_code.ui.modes.repl.clipboard import capture_clipboard_tag, copy_to_clipboard, extract_images_from_text
40
40
  from klaude_code.ui.modes.repl.completers import AT_TOKEN_PATTERN, create_repl_completer
@@ -11,8 +11,9 @@ import re
11
11
  from collections.abc import Callable
12
12
  from typing import cast
13
13
 
14
+ from prompt_toolkit.application.current import get_app
14
15
  from prompt_toolkit.buffer import Buffer
15
- from prompt_toolkit.filters import Always, Filter
16
+ from prompt_toolkit.filters import Always, Condition, Filter
16
17
  from prompt_toolkit.filters.app import has_completions
17
18
  from prompt_toolkit.key_binding import KeyBindings
18
19
  from prompt_toolkit.key_binding.key_processor import KeyPressEvent
@@ -40,6 +41,119 @@ def create_key_bindings(
40
41
  kb = KeyBindings()
41
42
  enabled = input_enabled if input_enabled is not None else Always()
42
43
 
44
+ def _can_move_cursor_visually_within_wrapped_line(delta_visible_y: int) -> bool:
45
+ """Return True when Up/Down should move within a wrapped visual line.
46
+
47
+ prompt_toolkit's default Up/Down behavior operates on logical lines
48
+ (split by '\n'). When a single logical line wraps across terminal
49
+ rows, pressing Up/Down should move within those wrapped rows instead of
50
+ triggering history navigation.
51
+
52
+ We only intercept when the cursor can move to an adjacent *visible*
53
+ line that maps to the same input line.
54
+ """
55
+
56
+ try:
57
+ app = get_app()
58
+ window = app.layout.current_window
59
+ ri = window.render_info
60
+ if ri is None:
61
+ return False
62
+
63
+ current_visible_y = int(ri.cursor_position.y)
64
+ target_visible_y = current_visible_y + delta_visible_y
65
+ if target_visible_y < 0:
66
+ return False
67
+
68
+ current_input_line = ri.visible_line_to_input_line.get(current_visible_y)
69
+ target_input_line = ri.visible_line_to_input_line.get(target_visible_y)
70
+ return current_input_line is not None and current_input_line == target_input_line
71
+ except Exception:
72
+ return False
73
+
74
+ def _move_cursor_visually_within_wrapped_line(event: KeyPressEvent, *, delta_visible_y: int) -> None:
75
+ """Move the cursor Up/Down by one wrapped screen row, keeping column."""
76
+
77
+ buf = event.current_buffer
78
+ try:
79
+ window = event.app.layout.current_window
80
+ ri = window.render_info
81
+ if ri is None:
82
+ return
83
+
84
+ rowcol_to_yx = getattr(ri, "_rowcol_to_yx", None)
85
+ x_offset = getattr(ri, "_x_offset", None)
86
+ y_offset = getattr(ri, "_y_offset", None)
87
+ if not isinstance(rowcol_to_yx, dict) or not isinstance(x_offset, int) or not isinstance(y_offset, int):
88
+ return
89
+ rowcol_to_yx_typed = cast(dict[tuple[int, int], tuple[int, int]], rowcol_to_yx)
90
+
91
+ current_visible_y = int(ri.cursor_position.y)
92
+ target_visible_y = current_visible_y + delta_visible_y
93
+ mapping = ri.visible_line_to_row_col
94
+ if current_visible_y not in mapping or target_visible_y not in mapping:
95
+ return
96
+
97
+ current_row, _ = mapping[current_visible_y]
98
+ target_row, _ = mapping[target_visible_y]
99
+
100
+ # Only handle wrapped rows within the same input line.
101
+ if current_row != target_row:
102
+ return
103
+
104
+ current_abs_y = y_offset + current_visible_y
105
+ target_abs_y = y_offset + target_visible_y
106
+ cursor_abs_x = x_offset + int(ri.cursor_position.x)
107
+
108
+ def _segment_start_abs_x(row: int, abs_y: int) -> int | None:
109
+ xs: list[int] = []
110
+ for (r, _col), (y, x) in rowcol_to_yx_typed.items():
111
+ if r == row and y == abs_y:
112
+ xs.append(x)
113
+ return min(xs) if xs else None
114
+
115
+ current_start_x = _segment_start_abs_x(current_row, current_abs_y)
116
+ target_start_x = _segment_start_abs_x(target_row, target_abs_y)
117
+ if current_start_x is None or target_start_x is None:
118
+ return
119
+
120
+ offset_in_segment_cells = max(0, cursor_abs_x - current_start_x)
121
+ desired_abs_x = target_start_x + offset_in_segment_cells
122
+
123
+ candidates: list[tuple[int, int]] = []
124
+ for (r, col), (y, x) in rowcol_to_yx_typed.items():
125
+ if r == target_row and y == target_abs_y:
126
+ candidates.append((col, x))
127
+ if not candidates:
128
+ return
129
+
130
+ # Pick the closest column at/before the desired X. If the desired
131
+ # position is before the first character, snap to the first.
132
+ candidates.sort(key=lambda t: t[1])
133
+ chosen_display_col = candidates[0][0]
134
+ for col, x in candidates:
135
+ if x <= desired_abs_x:
136
+ chosen_display_col = col
137
+ else:
138
+ break
139
+
140
+ control = event.app.layout.current_control
141
+ get_processed_line = getattr(control, "_last_get_processed_line", None)
142
+ target_source_col = chosen_display_col
143
+ if callable(get_processed_line):
144
+ processed_line = get_processed_line(target_row)
145
+ display_to_source = getattr(processed_line, "display_to_source", None)
146
+ if callable(display_to_source):
147
+ display_to_source_fn = cast(Callable[[int], int], display_to_source)
148
+ target_source_col = display_to_source_fn(chosen_display_col)
149
+
150
+ doc = buf.document # type: ignore[reportUnknownMemberType]
151
+ new_index = doc.translate_row_col_to_index(target_row, target_source_col) # type: ignore[reportUnknownMemberType]
152
+ buf.cursor_position = new_index # type: ignore[reportUnknownMemberType]
153
+ event.app.invalidate() # type: ignore[reportUnknownMemberType]
154
+ except Exception:
155
+ return
156
+
43
157
  def _should_submit_instead_of_accepting_completion(buf: Buffer) -> bool:
44
158
  """Return True when Enter should submit even if completions are visible.
45
159
 
@@ -174,6 +288,26 @@ def create_key_bindings(
174
288
  _cycle_completion(buf, delta=-1)
175
289
  event.app.invalidate() # type: ignore[reportUnknownMemberType]
176
290
 
291
+ @kb.add(
292
+ "up",
293
+ filter=enabled
294
+ & ~has_completions
295
+ & Condition(lambda: _can_move_cursor_visually_within_wrapped_line(delta_visible_y=-1)),
296
+ eager=True,
297
+ )
298
+ def _(event: KeyPressEvent) -> None:
299
+ _move_cursor_visually_within_wrapped_line(event, delta_visible_y=-1)
300
+
301
+ @kb.add(
302
+ "down",
303
+ filter=enabled
304
+ & ~has_completions
305
+ & Condition(lambda: _can_move_cursor_visually_within_wrapped_line(delta_visible_y=1)),
306
+ eager=True,
307
+ )
308
+ def _(event: KeyPressEvent) -> None:
309
+ _move_cursor_visually_within_wrapped_line(event, delta_visible_y=1)
310
+
177
311
  @kb.add("c-j", filter=enabled)
178
312
  def _(event: KeyPressEvent) -> None:
179
313
  event.current_buffer.insert_text("\n") # type: ignore
@@ -198,7 +332,7 @@ def create_key_bindings(
198
332
  """Ensure completions refresh on backspace when editing an @token.
199
333
 
200
334
  We delete the character before cursor (default behavior), then explicitly
201
- trigger completion refresh if the caret is still within an @... token.
335
+ trigger completion refresh if the caret is still within an @… token.
202
336
  """
203
337
  buf = event.current_buffer # type: ignore
204
338
  # Handle selection: cut selection if present, otherwise delete one character