klaude-code 1.2.1__py3-none-any.whl → 1.2.3__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 (140) hide show
  1. klaude_code/cli/main.py +9 -4
  2. klaude_code/cli/runtime.py +42 -43
  3. klaude_code/command/__init__.py +7 -5
  4. klaude_code/command/clear_cmd.py +6 -29
  5. klaude_code/command/command_abc.py +44 -8
  6. klaude_code/command/diff_cmd.py +33 -27
  7. klaude_code/command/export_cmd.py +18 -26
  8. klaude_code/command/help_cmd.py +10 -8
  9. klaude_code/command/model_cmd.py +11 -40
  10. klaude_code/command/{prompt-update-dev-doc.md → prompt-dev-docs-update.md} +3 -2
  11. klaude_code/command/{prompt-dev-doc.md → prompt-dev-docs.md} +3 -2
  12. klaude_code/command/prompt-init.md +2 -5
  13. klaude_code/command/prompt_command.py +6 -6
  14. klaude_code/command/refresh_cmd.py +4 -5
  15. klaude_code/command/registry.py +16 -19
  16. klaude_code/command/terminal_setup_cmd.py +12 -11
  17. klaude_code/config/__init__.py +4 -0
  18. klaude_code/config/config.py +25 -26
  19. klaude_code/config/list_model.py +8 -3
  20. klaude_code/config/select_model.py +1 -1
  21. klaude_code/const/__init__.py +1 -1
  22. klaude_code/core/__init__.py +0 -3
  23. klaude_code/core/agent.py +25 -50
  24. klaude_code/core/executor.py +268 -101
  25. klaude_code/core/prompt.py +12 -12
  26. klaude_code/core/{prompt → prompts}/prompt-gemini.md +1 -1
  27. klaude_code/core/reminders.py +76 -95
  28. klaude_code/core/task.py +21 -14
  29. klaude_code/core/tool/__init__.py +45 -11
  30. klaude_code/core/tool/file/apply_patch.py +5 -1
  31. klaude_code/core/tool/file/apply_patch_tool.py +11 -13
  32. klaude_code/core/tool/file/edit_tool.py +27 -23
  33. klaude_code/core/tool/file/multi_edit_tool.py +15 -17
  34. klaude_code/core/tool/file/read_tool.py +41 -36
  35. klaude_code/core/tool/file/write_tool.py +13 -15
  36. klaude_code/core/tool/memory/memory_tool.py +85 -68
  37. klaude_code/core/tool/memory/skill_tool.py +10 -12
  38. klaude_code/core/tool/shell/bash_tool.py +24 -22
  39. klaude_code/core/tool/shell/command_safety.py +12 -1
  40. klaude_code/core/tool/sub_agent_tool.py +11 -12
  41. klaude_code/core/tool/todo/todo_write_tool.py +21 -28
  42. klaude_code/core/tool/todo/update_plan_tool.py +14 -24
  43. klaude_code/core/tool/tool_abc.py +3 -4
  44. klaude_code/core/tool/tool_context.py +7 -7
  45. klaude_code/core/tool/tool_registry.py +30 -47
  46. klaude_code/core/tool/tool_runner.py +35 -43
  47. klaude_code/core/tool/truncation.py +14 -20
  48. klaude_code/core/tool/web/mermaid_tool.py +12 -14
  49. klaude_code/core/tool/web/web_fetch_tool.py +15 -17
  50. klaude_code/core/turn.py +19 -7
  51. klaude_code/llm/__init__.py +3 -4
  52. klaude_code/llm/anthropic/client.py +30 -46
  53. klaude_code/llm/anthropic/input.py +4 -11
  54. klaude_code/llm/client.py +29 -8
  55. klaude_code/llm/input_common.py +66 -36
  56. klaude_code/llm/openai_compatible/client.py +42 -84
  57. klaude_code/llm/openai_compatible/input.py +11 -16
  58. klaude_code/llm/openai_compatible/tool_call_accumulator.py +2 -2
  59. klaude_code/llm/openrouter/client.py +40 -289
  60. klaude_code/llm/openrouter/input.py +13 -35
  61. klaude_code/llm/openrouter/reasoning_handler.py +209 -0
  62. klaude_code/llm/registry.py +5 -75
  63. klaude_code/llm/responses/client.py +34 -55
  64. klaude_code/llm/responses/input.py +24 -26
  65. klaude_code/llm/usage.py +109 -0
  66. klaude_code/protocol/__init__.py +4 -0
  67. klaude_code/protocol/events.py +3 -2
  68. klaude_code/protocol/{llm_parameter.py → llm_param.py} +12 -32
  69. klaude_code/protocol/model.py +49 -4
  70. klaude_code/protocol/op.py +18 -16
  71. klaude_code/protocol/op_handler.py +28 -0
  72. klaude_code/{core → protocol}/sub_agent.py +7 -0
  73. klaude_code/session/export.py +150 -70
  74. klaude_code/session/session.py +28 -14
  75. klaude_code/session/templates/export_session.html +180 -42
  76. klaude_code/trace/__init__.py +2 -2
  77. klaude_code/trace/log.py +11 -5
  78. klaude_code/ui/__init__.py +91 -8
  79. klaude_code/ui/core/__init__.py +1 -0
  80. klaude_code/ui/core/display.py +103 -0
  81. klaude_code/ui/core/input.py +71 -0
  82. klaude_code/ui/modes/__init__.py +1 -0
  83. klaude_code/ui/modes/debug/__init__.py +1 -0
  84. klaude_code/ui/{base/debug_event_display.py → modes/debug/display.py} +9 -5
  85. klaude_code/ui/modes/exec/__init__.py +1 -0
  86. klaude_code/ui/{base/exec_display.py → modes/exec/display.py} +28 -2
  87. klaude_code/ui/{repl → modes/repl}/__init__.py +5 -6
  88. klaude_code/ui/modes/repl/clipboard.py +152 -0
  89. klaude_code/ui/modes/repl/completers.py +429 -0
  90. klaude_code/ui/modes/repl/display.py +60 -0
  91. klaude_code/ui/modes/repl/event_handler.py +375 -0
  92. klaude_code/ui/modes/repl/input_prompt_toolkit.py +198 -0
  93. klaude_code/ui/modes/repl/key_bindings.py +170 -0
  94. klaude_code/ui/{repl → modes/repl}/renderer.py +109 -132
  95. klaude_code/ui/renderers/assistant.py +21 -0
  96. klaude_code/ui/renderers/common.py +0 -16
  97. klaude_code/ui/renderers/developer.py +18 -18
  98. klaude_code/ui/renderers/diffs.py +36 -14
  99. klaude_code/ui/renderers/errors.py +1 -1
  100. klaude_code/ui/renderers/metadata.py +50 -27
  101. klaude_code/ui/renderers/sub_agent.py +43 -9
  102. klaude_code/ui/renderers/thinking.py +33 -1
  103. klaude_code/ui/renderers/tools.py +212 -20
  104. klaude_code/ui/renderers/user_input.py +19 -23
  105. klaude_code/ui/rich/__init__.py +1 -0
  106. klaude_code/ui/{rich_ext → rich}/searchable_text.py +3 -1
  107. klaude_code/ui/{renderers → rich}/status.py +29 -18
  108. klaude_code/ui/{base → rich}/theme.py +8 -2
  109. klaude_code/ui/terminal/__init__.py +1 -0
  110. klaude_code/ui/{base/terminal_color.py → terminal/color.py} +4 -1
  111. klaude_code/ui/{base/terminal_control.py → terminal/control.py} +1 -0
  112. klaude_code/ui/{base/terminal_notifier.py → terminal/notifier.py} +5 -2
  113. klaude_code/ui/utils/__init__.py +1 -0
  114. klaude_code/ui/{base/utils.py → utils/common.py} +35 -3
  115. {klaude_code-1.2.1.dist-info → klaude_code-1.2.3.dist-info}/METADATA +1 -1
  116. klaude_code-1.2.3.dist-info/RECORD +161 -0
  117. klaude_code/core/clipboard_manifest.py +0 -124
  118. klaude_code/llm/openrouter/tool_call_accumulator.py +0 -80
  119. klaude_code/ui/base/__init__.py +0 -1
  120. klaude_code/ui/base/display_abc.py +0 -36
  121. klaude_code/ui/base/input_abc.py +0 -20
  122. klaude_code/ui/repl/display.py +0 -36
  123. klaude_code/ui/repl/event_handler.py +0 -247
  124. klaude_code/ui/repl/input.py +0 -773
  125. klaude_code/ui/rich_ext/__init__.py +0 -1
  126. klaude_code-1.2.1.dist-info/RECORD +0 -151
  127. /klaude_code/core/{prompt → prompts}/prompt-claude-code.md +0 -0
  128. /klaude_code/core/{prompt → prompts}/prompt-codex.md +0 -0
  129. /klaude_code/core/{prompt → prompts}/prompt-subagent-explore.md +0 -0
  130. /klaude_code/core/{prompt → prompts}/prompt-subagent-oracle.md +0 -0
  131. /klaude_code/core/{prompt → prompts}/prompt-subagent-webfetch.md +0 -0
  132. /klaude_code/core/{prompt → prompts}/prompt-subagent.md +0 -0
  133. /klaude_code/ui/{base → core}/stage_manager.py +0 -0
  134. /klaude_code/ui/{rich_ext → rich}/live.py +0 -0
  135. /klaude_code/ui/{rich_ext → rich}/markdown.py +0 -0
  136. /klaude_code/ui/{rich_ext → rich}/quote.py +0 -0
  137. /klaude_code/ui/{base → terminal}/progress_bar.py +0 -0
  138. /klaude_code/ui/{base → utils}/debouncer.py +0 -0
  139. {klaude_code-1.2.1.dist-info → klaude_code-1.2.3.dist-info}/WHEEL +0 -0
  140. {klaude_code-1.2.1.dist-info → klaude_code-1.2.3.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,375 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Awaitable, Callable
4
+
5
+ from rich.text import Text
6
+
7
+ from klaude_code import const
8
+ from klaude_code.protocol import events
9
+ from klaude_code.ui.core.stage_manager import Stage, StageManager
10
+ from klaude_code.ui.modes.repl.renderer import REPLRenderer
11
+ from klaude_code.ui.rich.markdown import MarkdownStream
12
+ from klaude_code.ui.terminal.notifier import Notification, NotificationType, TerminalNotifier
13
+ from klaude_code.ui.terminal.progress_bar import OSC94States, emit_osc94
14
+ from klaude_code.ui.utils.debouncer import Debouncer
15
+
16
+
17
+ class StreamState:
18
+ def __init__(self, interval: float, flush_handler: Callable[["StreamState"], Awaitable[None]]):
19
+ self.buffer: str = ""
20
+ self.mdstream: MarkdownStream | None = None
21
+ self._flush_handler = flush_handler
22
+ self.debouncer = Debouncer(interval=interval, callback=self._debounced_flush)
23
+
24
+ async def _debounced_flush(self) -> None:
25
+ await self._flush_handler(self)
26
+
27
+ def append(self, content: str) -> None:
28
+ self.buffer += content
29
+
30
+ def clear(self) -> None:
31
+ self.buffer = ""
32
+
33
+
34
+ class SpinnerStatusState:
35
+ """Multi-layer spinner status state management.
36
+
37
+ Layers (from low to high priority):
38
+ - base_status: Set by TodoChange, persistent within a turn
39
+ - composing: True when assistant is streaming text
40
+ - tool_calls: Accumulated from ToolCallStart
41
+
42
+ Display logic:
43
+ - If tool_calls: show base + tool_calls (composing is hidden)
44
+ - Elif composing: show base + "Composing"
45
+ - Elif base_status: show base_status
46
+ - Else: show "Thinking …"
47
+ """
48
+
49
+ DEFAULT_STATUS = "Thinking …"
50
+
51
+ def __init__(self) -> None:
52
+ self._base_status: str | None = None
53
+ self._composing: bool = False
54
+ self._tool_calls: dict[str, int] = {}
55
+ self._pending_clear: bool = False
56
+
57
+ def reset(self) -> None:
58
+ """Reset all layers."""
59
+ self._base_status = None
60
+ self._composing = False
61
+ self._tool_calls = {}
62
+ self._pending_clear = False
63
+
64
+ def set_base_status(self, status: str | None) -> None:
65
+ """Set base status from TodoChange."""
66
+ self._base_status = status
67
+
68
+ def set_composing(self, composing: bool) -> None:
69
+ """Set composing state when assistant is streaming."""
70
+ self._composing = composing
71
+
72
+ def add_tool_call(self, tool_name: str) -> None:
73
+ """Add a tool call to the accumulator."""
74
+ if self._pending_clear:
75
+ self._tool_calls = {}
76
+ self._composing = False
77
+ self._pending_clear = False
78
+ self._tool_calls[tool_name] = self._tool_calls.get(tool_name, 0) + 1
79
+
80
+ def clear_tool_calls(self) -> None:
81
+ """Clear tool calls and composing state immediately."""
82
+ self._tool_calls = {}
83
+ self._composing = False
84
+ self._pending_clear = False
85
+
86
+ def mark_pending_clear(self) -> None:
87
+ """Mark tool calls to be cleared on next add_tool_call or set_composing."""
88
+ self._pending_clear = True
89
+
90
+ def get_status(self) -> Text:
91
+ """Get current spinner status as rich Text."""
92
+ # Build activity text (tool_calls or composing)
93
+ activity_text: Text | None = None
94
+ if self._tool_calls:
95
+ activity_text = Text()
96
+ first = True
97
+ for name, count in self._tool_calls.items():
98
+ if not first:
99
+ activity_text.append(", ")
100
+ activity_text.append(name, style="bold")
101
+ if count > 1:
102
+ activity_text.append(f" × {count}")
103
+ first = False
104
+ elif self._composing:
105
+ activity_text = Text("Composing")
106
+
107
+ if self._base_status:
108
+ result = Text(self._base_status)
109
+ if activity_text:
110
+ result.append(" | ")
111
+ result.append_text(activity_text)
112
+ return result
113
+ if activity_text:
114
+ activity_text.append(" …")
115
+ return activity_text
116
+ return Text(self.DEFAULT_STATUS)
117
+
118
+
119
+ class DisplayEventHandler:
120
+ """Handle REPL events, buffering and delegating rendering work."""
121
+
122
+ def __init__(self, renderer: REPLRenderer, notifier: TerminalNotifier | None = None):
123
+ self.renderer = renderer
124
+ self.notifier = notifier
125
+ self.assistant_stream = StreamState(
126
+ interval=1 / const.UI_REFRESH_RATE_FPS, flush_handler=self._flush_assistant_buffer
127
+ )
128
+ self.spinner_status = SpinnerStatusState()
129
+
130
+ self.stage_manager = StageManager(
131
+ finish_assistant=self._finish_assistant_stream,
132
+ on_enter_thinking=self._print_thinking_prefix,
133
+ )
134
+
135
+ async def consume_event(self, event: events.Event) -> None:
136
+ match event:
137
+ case events.ReplayHistoryEvent() as e:
138
+ await self._on_replay_history(e)
139
+ case events.WelcomeEvent() as e:
140
+ self._on_welcome(e)
141
+ case events.UserMessageEvent() as e:
142
+ self._on_user_message(e)
143
+ case events.TaskStartEvent() as e:
144
+ self._on_task_start(e)
145
+ case events.DeveloperMessageEvent() as e:
146
+ self._on_developer_message(e)
147
+ case events.TurnStartEvent() as e:
148
+ self._on_turn_start(e)
149
+ case events.ThinkingEvent() as e:
150
+ await self._on_thinking(e)
151
+ case events.AssistantMessageDeltaEvent() as e:
152
+ await self._on_assistant_delta(e)
153
+ case events.AssistantMessageEvent() as e:
154
+ await self._on_assistant_message(e)
155
+ case events.TurnToolCallStartEvent() as e:
156
+ self._on_tool_call_start(e)
157
+ case events.ToolCallEvent() as e:
158
+ await self._on_tool_call(e)
159
+ case events.ToolResultEvent() as e:
160
+ await self._on_tool_result(e)
161
+ case events.ResponseMetadataEvent() as e:
162
+ self._on_response_metadata(e)
163
+ case events.TodoChangeEvent() as e:
164
+ self._on_todo_change(e)
165
+ case events.TurnEndEvent():
166
+ pass
167
+ case events.TaskFinishEvent() as e:
168
+ await self._on_task_finish(e)
169
+ case events.InterruptEvent() as e:
170
+ await self._on_interrupt(e)
171
+ case events.ErrorEvent() as e:
172
+ await self._on_error(e)
173
+ case events.EndEvent() as e:
174
+ await self._on_end(e)
175
+
176
+ async def stop(self) -> None:
177
+ await self.assistant_stream.debouncer.flush()
178
+ self.assistant_stream.debouncer.cancel()
179
+
180
+ # ─────────────────────────────────────────────────────────────────────────────
181
+ # Private event handlers
182
+ # ─────────────────────────────────────────────────────────────────────────────
183
+
184
+ async def _on_replay_history(self, event: events.ReplayHistoryEvent) -> None:
185
+ await self.renderer.replay_history(event)
186
+ self.renderer.spinner_stop()
187
+
188
+ def _on_welcome(self, event: events.WelcomeEvent) -> None:
189
+ self.renderer.display_welcome(event)
190
+
191
+ def _on_user_message(self, event: events.UserMessageEvent) -> None:
192
+ self.renderer.display_user_message(event)
193
+
194
+ def _on_task_start(self, event: events.TaskStartEvent) -> None:
195
+ self.renderer.spinner_start()
196
+ self.renderer.display_task_start(event)
197
+ emit_osc94(OSC94States.INDETERMINATE)
198
+
199
+ def _on_developer_message(self, event: events.DeveloperMessageEvent) -> None:
200
+ self.renderer.display_developer_message(event)
201
+ self.renderer.display_command_output(event)
202
+
203
+ def _on_turn_start(self, event: events.TurnStartEvent) -> None:
204
+ emit_osc94(OSC94States.INDETERMINATE)
205
+ self.renderer.display_turn_start(event)
206
+ self.spinner_status.mark_pending_clear()
207
+
208
+ async def _on_thinking(self, event: events.ThinkingEvent) -> None:
209
+ if self.renderer.is_sub_agent_session(event.session_id):
210
+ return
211
+ self._clear_and_update_spinner()
212
+ await self.stage_manager.enter_thinking_stage()
213
+ self.renderer.display_thinking(event.content)
214
+
215
+ async def _on_assistant_delta(self, event: events.AssistantMessageDeltaEvent) -> None:
216
+ if self.renderer.is_sub_agent_session(event.session_id):
217
+ return
218
+ if len(event.content.strip()) == 0 and self.stage_manager.current_stage != Stage.ASSISTANT:
219
+ return
220
+ first_delta = self.assistant_stream.mdstream is None
221
+ if first_delta:
222
+ self.spinner_status.clear_tool_calls()
223
+ self.spinner_status.set_composing(True)
224
+ self._update_spinner()
225
+ self.assistant_stream.mdstream = MarkdownStream(
226
+ mdargs={"code_theme": self.renderer.themes.code_theme},
227
+ theme=self.renderer.themes.markdown_theme,
228
+ console=self.renderer.console,
229
+ spinner=self.renderer.spinner_renderable(),
230
+ mark="➤",
231
+ indent=2,
232
+ )
233
+ self.assistant_stream.append(event.content)
234
+ if first_delta and self.assistant_stream.mdstream is not None:
235
+ # Stop spinner and immediately start MarkdownStream's Live
236
+ # to avoid flicker. The update() call starts the Live with
237
+ # the spinner embedded, providing seamless transition.
238
+ self.renderer.spinner_stop()
239
+ self.assistant_stream.mdstream.update(self.assistant_stream.buffer)
240
+ await self.stage_manager.transition_to(Stage.ASSISTANT)
241
+ self.assistant_stream.debouncer.schedule()
242
+
243
+ async def _on_assistant_message(self, event: events.AssistantMessageEvent) -> None:
244
+ if self.renderer.is_sub_agent_session(event.session_id):
245
+ return
246
+ await self.stage_manager.transition_to(Stage.ASSISTANT)
247
+ if self.assistant_stream.mdstream is not None:
248
+ self.assistant_stream.debouncer.cancel()
249
+ self.assistant_stream.mdstream.update(event.content.strip(), final=True)
250
+ else:
251
+ self.renderer.display_assistant_message(event.content)
252
+ self.assistant_stream.clear()
253
+ self.assistant_stream.mdstream = None
254
+ self.spinner_status.set_composing(False)
255
+ await self.stage_manager.transition_to(Stage.WAITING)
256
+ self.renderer.spinner_start()
257
+
258
+ def _on_tool_call_start(self, event: events.TurnToolCallStartEvent) -> None:
259
+ from klaude_code.ui.renderers.tools import get_tool_active_form
260
+
261
+ self.spinner_status.set_composing(False)
262
+ self.spinner_status.add_tool_call(get_tool_active_form(event.tool_name))
263
+ self._update_spinner()
264
+
265
+ async def _on_tool_call(self, event: events.ToolCallEvent) -> None:
266
+ await self.stage_manager.transition_to(Stage.TOOL_CALL)
267
+ with self.renderer.session_print_context(event.session_id):
268
+ self.renderer.display_tool_call(event)
269
+
270
+ async def _on_tool_result(self, event: events.ToolResultEvent) -> None:
271
+ if self.renderer.is_sub_agent_session(event.session_id):
272
+ return
273
+ await self.stage_manager.transition_to(Stage.TOOL_RESULT)
274
+ self.renderer.display_tool_call_result(event)
275
+
276
+ def _on_response_metadata(self, event: events.ResponseMetadataEvent) -> None:
277
+ self.renderer.display_response_metadata(event)
278
+
279
+ def _on_todo_change(self, event: events.TodoChangeEvent) -> None:
280
+ active_form_status_text = self._extract_active_form_text(event)
281
+ self.spinner_status.set_base_status(active_form_status_text if active_form_status_text else None)
282
+ # Clear tool calls when todo changes, as the tool execution has advanced
283
+ self._clear_and_update_spinner()
284
+
285
+ async def _on_task_finish(self, event: events.TaskFinishEvent) -> None:
286
+ self.renderer.display_task_finish(event)
287
+ if not self.renderer.is_sub_agent_session(event.session_id):
288
+ emit_osc94(OSC94States.HIDDEN)
289
+ self.spinner_status.reset()
290
+ self.renderer.spinner_stop()
291
+ await self.stage_manager.transition_to(Stage.WAITING)
292
+ self._maybe_notify_task_finish(event)
293
+
294
+ async def _on_interrupt(self, event: events.InterruptEvent) -> None:
295
+ self.renderer.spinner_stop()
296
+ self.spinner_status.reset()
297
+ await self.stage_manager.transition_to(Stage.WAITING)
298
+ emit_osc94(OSC94States.HIDDEN)
299
+ self.renderer.display_interrupt()
300
+
301
+ async def _on_error(self, event: events.ErrorEvent) -> None:
302
+ emit_osc94(OSC94States.ERROR)
303
+ await self.stage_manager.transition_to(Stage.WAITING)
304
+ self.renderer.display_error(event)
305
+ if not event.can_retry:
306
+ self.renderer.spinner_stop()
307
+ self.spinner_status.reset()
308
+
309
+ async def _on_end(self, event: events.EndEvent) -> None:
310
+ emit_osc94(OSC94States.HIDDEN)
311
+ await self.stage_manager.transition_to(Stage.WAITING)
312
+ self.renderer.spinner_stop()
313
+ self.spinner_status.reset()
314
+
315
+ # ─────────────────────────────────────────────────────────────────────────────
316
+ # Private helper methods
317
+ # ─────────────────────────────────────────────────────────────────────────────
318
+
319
+ async def _finish_assistant_stream(self) -> None:
320
+ if self.assistant_stream.mdstream is not None:
321
+ self.assistant_stream.debouncer.cancel()
322
+ self.assistant_stream.mdstream.update(self.assistant_stream.buffer, final=True)
323
+ self.assistant_stream.mdstream = None
324
+ self.assistant_stream.clear()
325
+
326
+ def _print_thinking_prefix(self) -> None:
327
+ self.renderer.display_thinking_prefix()
328
+
329
+ def _update_spinner(self) -> None:
330
+ """Update spinner text from current status state."""
331
+ self.renderer.spinner_update(self.spinner_status.get_status())
332
+
333
+ def _clear_and_update_spinner(self) -> None:
334
+ """Clear tool calls and update spinner."""
335
+ self.spinner_status.clear_tool_calls()
336
+ self._update_spinner()
337
+
338
+ async def _flush_assistant_buffer(self, state: StreamState) -> None:
339
+ if state.mdstream is not None:
340
+ state.mdstream.update(state.buffer)
341
+
342
+ def _maybe_notify_task_finish(self, event: events.TaskFinishEvent) -> None:
343
+ if self.notifier is None:
344
+ return
345
+ if self.renderer.is_sub_agent_session(event.session_id):
346
+ return
347
+ notification = self._build_task_finish_notification(event)
348
+ self.notifier.notify(notification)
349
+
350
+ def _build_task_finish_notification(self, event: events.TaskFinishEvent) -> Notification:
351
+ body = self._compact_result_text(event.task_result)
352
+ return Notification(
353
+ type=NotificationType.AGENT_TASK_COMPLETE,
354
+ title="Task Completed",
355
+ body=body,
356
+ )
357
+
358
+ def _compact_result_text(self, text: str) -> str | None:
359
+ stripped = text.strip()
360
+ if len(stripped) == 0:
361
+ return None
362
+ squashed = " ".join(stripped.split())
363
+ if len(squashed) > 200:
364
+ return squashed[:197] + "…"
365
+ return squashed
366
+
367
+ def _extract_active_form_text(self, todo_event: events.TodoChangeEvent) -> str:
368
+ status_text = ""
369
+ for todo in todo_event.todos:
370
+ if todo.status == "in_progress":
371
+ if len(todo.activeForm) > 0:
372
+ status_text = todo.activeForm
373
+ if len(todo.content) > 0:
374
+ status_text = todo.content
375
+ return status_text.replace("\n", "")
@@ -0,0 +1,198 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ from collections.abc import AsyncIterator, Callable
5
+ from pathlib import Path
6
+ from typing import NamedTuple, override
7
+
8
+ from prompt_toolkit import PromptSession
9
+ from prompt_toolkit.buffer import Buffer
10
+ from prompt_toolkit.completion import ThreadedCompleter
11
+ from prompt_toolkit.filters import Condition
12
+ from prompt_toolkit.formatted_text import FormattedText
13
+ from prompt_toolkit.history import FileHistory
14
+ from prompt_toolkit.patch_stdout import patch_stdout
15
+ from prompt_toolkit.styles import Style
16
+
17
+ from klaude_code.protocol.model import UserInputPayload
18
+ from klaude_code.ui.core.input import InputProviderABC
19
+ from klaude_code.ui.modes.repl.clipboard import capture_clipboard_tag, copy_to_clipboard, extract_images_from_text
20
+ from klaude_code.ui.modes.repl.completers import AT_TOKEN_PATTERN, create_repl_completer
21
+ from klaude_code.ui.modes.repl.key_bindings import create_key_bindings
22
+ from klaude_code.ui.utils.common import get_current_git_branch, show_path_with_tilde
23
+
24
+
25
+ class REPLStatusSnapshot(NamedTuple):
26
+ """Snapshot of REPL status for bottom toolbar display."""
27
+
28
+ model_name: str
29
+ context_usage_percent: float | None
30
+ llm_calls: int
31
+ tool_calls: int
32
+ update_message: str | None = None
33
+
34
+
35
+ COMPLETION_SELECTED = "#5869f7"
36
+ COMPLETION_MENU = "ansibrightblack"
37
+ INPUT_PROMPT_STYLE = "ansimagenta"
38
+
39
+
40
+ class PromptToolkitInput(InputProviderABC):
41
+ def __init__(
42
+ self,
43
+ prompt: str = "❯ ",
44
+ status_provider: Callable[[], REPLStatusSnapshot] | None = None,
45
+ ): # ▌
46
+ self._status_provider = status_provider
47
+
48
+ # Mouse is disabled by default; only enabled when input becomes multi-line.
49
+ self._mouse_enabled: bool = False
50
+
51
+ project = str(Path.cwd()).strip("/").replace("/", "-")
52
+ history_path = Path.home() / ".klaude" / "projects" / f"{project}" / "input_history.txt"
53
+
54
+ if not history_path.parent.exists():
55
+ history_path.parent.mkdir(parents=True, exist_ok=True)
56
+ if not history_path.exists():
57
+ history_path.touch()
58
+
59
+ mouse_support_filter = Condition(lambda: self._mouse_enabled)
60
+
61
+ # Create key bindings with injected dependencies
62
+ kb = create_key_bindings(
63
+ capture_clipboard_tag=capture_clipboard_tag,
64
+ copy_to_clipboard=copy_to_clipboard,
65
+ at_token_pattern=AT_TOKEN_PATTERN,
66
+ )
67
+
68
+ self._session: PromptSession[str] = PromptSession(
69
+ [(INPUT_PROMPT_STYLE, prompt)],
70
+ history=FileHistory(history_path),
71
+ multiline=True,
72
+ prompt_continuation=[(INPUT_PROMPT_STYLE, " ")],
73
+ key_bindings=kb,
74
+ completer=ThreadedCompleter(create_repl_completer()),
75
+ complete_while_typing=True,
76
+ erase_when_done=True,
77
+ bottom_toolbar=self._render_bottom_toolbar,
78
+ mouse_support=mouse_support_filter,
79
+ style=Style.from_dict(
80
+ {
81
+ "completion-menu": "bg:default",
82
+ "completion-menu.border": "bg:default",
83
+ "scrollbar.background": "bg:default",
84
+ "scrollbar.button": "bg:default",
85
+ "completion-menu.completion": f"bg:default fg:{COMPLETION_MENU}",
86
+ "completion-menu.meta.completion": f"bg:default fg:{COMPLETION_MENU}",
87
+ "completion-menu.completion.current": f"noreverse bg:default fg:{COMPLETION_SELECTED} bold",
88
+ "completion-menu.meta.completion.current": f"bg:default fg:{COMPLETION_SELECTED} bold",
89
+ }
90
+ ),
91
+ )
92
+
93
+ try:
94
+ self._session.default_buffer.on_text_changed += self._on_buffer_text_changed
95
+ except Exception:
96
+ # If we can't hook the buffer events for any reason, fall back to static behavior.
97
+ pass
98
+
99
+ def _render_bottom_toolbar(self) -> FormattedText:
100
+ """Render bottom toolbar with working directory, git branch on left, model name and context usage on right.
101
+
102
+ If an update is available, only show the update message on the left side.
103
+ """
104
+ # Check for update message first
105
+ update_message: str | None = None
106
+ if self._status_provider:
107
+ try:
108
+ status = self._status_provider()
109
+ update_message = status.update_message
110
+ except Exception:
111
+ pass
112
+
113
+ # If update available, show only the update message
114
+ if update_message:
115
+ left_text = " " + update_message
116
+ try:
117
+ terminal_width = shutil.get_terminal_size().columns
118
+ padding = " " * max(0, terminal_width - len(left_text))
119
+ except Exception:
120
+ padding = ""
121
+ toolbar_text = left_text + padding
122
+ return FormattedText([("#ansiyellow", toolbar_text)])
123
+
124
+ # Normal mode: Left side: path and git branch
125
+ left_parts: list[str] = []
126
+ left_parts.append(show_path_with_tilde())
127
+
128
+ git_branch = get_current_git_branch()
129
+ if git_branch:
130
+ left_parts.append(git_branch)
131
+
132
+ # Right side: status info
133
+ right_parts: list[str] = []
134
+ if self._status_provider:
135
+ try:
136
+ status = self._status_provider()
137
+ model_name = status.model_name or "N/A"
138
+ right_parts.append(model_name)
139
+
140
+ # Add context if available
141
+ if status.context_usage_percent is not None:
142
+ right_parts.append(f"context {status.context_usage_percent:.1f}%")
143
+ except Exception:
144
+ pass
145
+
146
+ # Build left and right text with borders
147
+ left_text = " " + " · ".join(left_parts)
148
+ right_text = (" · ".join(right_parts) + " ") if right_parts else " "
149
+
150
+ # Calculate padding
151
+ try:
152
+ terminal_width = shutil.get_terminal_size().columns
153
+ used_width = len(left_text) + len(right_text)
154
+ padding = " " * max(0, terminal_width - used_width)
155
+ except Exception:
156
+ padding = ""
157
+
158
+ # Build result with style
159
+ toolbar_text = left_text + padding + right_text
160
+ return FormattedText([("#ansiblue", toolbar_text)])
161
+
162
+ async def start(self) -> None:
163
+ pass
164
+
165
+ async def stop(self) -> None:
166
+ pass
167
+
168
+ @override
169
+ async def iter_inputs(self) -> AsyncIterator[UserInputPayload]:
170
+ while True:
171
+ # For each new prompt, start with mouse disabled so users can select history.
172
+ self._mouse_enabled = False
173
+ with patch_stdout():
174
+ line: str = await self._session.prompt_async()
175
+
176
+ # Extract images referenced in the input text
177
+ images = extract_images_from_text(line)
178
+
179
+ yield UserInputPayload(text=line, images=images if images else None)
180
+
181
+ def _on_buffer_text_changed(self, buf: Buffer) -> None:
182
+ """Toggle mouse support based on current buffer content.
183
+
184
+ Mouse stays disabled when input is empty. It is enabled only when
185
+ the user has entered more than one line of text.
186
+ """
187
+ try:
188
+ text = buf.text
189
+ except Exception:
190
+ return
191
+ self._mouse_enabled = self._should_enable_mouse(text)
192
+
193
+ def _should_enable_mouse(self, text: str) -> bool:
194
+ """Return True when mouse support should be enabled for current input."""
195
+ if not text.strip():
196
+ return False
197
+ # Enable mouse only when input spans multiple lines.
198
+ return "\n" in text