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.
- klaude_code/cli/main.py +9 -4
- klaude_code/cli/runtime.py +42 -43
- klaude_code/command/__init__.py +7 -5
- klaude_code/command/clear_cmd.py +6 -29
- klaude_code/command/command_abc.py +44 -8
- klaude_code/command/diff_cmd.py +33 -27
- klaude_code/command/export_cmd.py +18 -26
- klaude_code/command/help_cmd.py +10 -8
- klaude_code/command/model_cmd.py +11 -40
- klaude_code/command/{prompt-update-dev-doc.md → prompt-dev-docs-update.md} +3 -2
- klaude_code/command/{prompt-dev-doc.md → prompt-dev-docs.md} +3 -2
- klaude_code/command/prompt-init.md +2 -5
- klaude_code/command/prompt_command.py +6 -6
- klaude_code/command/refresh_cmd.py +4 -5
- klaude_code/command/registry.py +16 -19
- klaude_code/command/terminal_setup_cmd.py +12 -11
- klaude_code/config/__init__.py +4 -0
- klaude_code/config/config.py +25 -26
- klaude_code/config/list_model.py +8 -3
- klaude_code/config/select_model.py +1 -1
- klaude_code/const/__init__.py +1 -1
- klaude_code/core/__init__.py +0 -3
- klaude_code/core/agent.py +25 -50
- klaude_code/core/executor.py +268 -101
- klaude_code/core/prompt.py +12 -12
- klaude_code/core/{prompt → prompts}/prompt-gemini.md +1 -1
- klaude_code/core/reminders.py +76 -95
- klaude_code/core/task.py +21 -14
- klaude_code/core/tool/__init__.py +45 -11
- klaude_code/core/tool/file/apply_patch.py +5 -1
- klaude_code/core/tool/file/apply_patch_tool.py +11 -13
- klaude_code/core/tool/file/edit_tool.py +27 -23
- klaude_code/core/tool/file/multi_edit_tool.py +15 -17
- klaude_code/core/tool/file/read_tool.py +41 -36
- klaude_code/core/tool/file/write_tool.py +13 -15
- klaude_code/core/tool/memory/memory_tool.py +85 -68
- klaude_code/core/tool/memory/skill_tool.py +10 -12
- klaude_code/core/tool/shell/bash_tool.py +24 -22
- klaude_code/core/tool/shell/command_safety.py +12 -1
- klaude_code/core/tool/sub_agent_tool.py +11 -12
- klaude_code/core/tool/todo/todo_write_tool.py +21 -28
- klaude_code/core/tool/todo/update_plan_tool.py +14 -24
- klaude_code/core/tool/tool_abc.py +3 -4
- klaude_code/core/tool/tool_context.py +7 -7
- klaude_code/core/tool/tool_registry.py +30 -47
- klaude_code/core/tool/tool_runner.py +35 -43
- klaude_code/core/tool/truncation.py +14 -20
- klaude_code/core/tool/web/mermaid_tool.py +12 -14
- klaude_code/core/tool/web/web_fetch_tool.py +15 -17
- klaude_code/core/turn.py +19 -7
- klaude_code/llm/__init__.py +3 -4
- klaude_code/llm/anthropic/client.py +30 -46
- klaude_code/llm/anthropic/input.py +4 -11
- klaude_code/llm/client.py +29 -8
- klaude_code/llm/input_common.py +66 -36
- klaude_code/llm/openai_compatible/client.py +42 -84
- klaude_code/llm/openai_compatible/input.py +11 -16
- klaude_code/llm/openai_compatible/tool_call_accumulator.py +2 -2
- klaude_code/llm/openrouter/client.py +40 -289
- klaude_code/llm/openrouter/input.py +13 -35
- klaude_code/llm/openrouter/reasoning_handler.py +209 -0
- klaude_code/llm/registry.py +5 -75
- klaude_code/llm/responses/client.py +34 -55
- klaude_code/llm/responses/input.py +24 -26
- klaude_code/llm/usage.py +109 -0
- klaude_code/protocol/__init__.py +4 -0
- klaude_code/protocol/events.py +3 -2
- klaude_code/protocol/{llm_parameter.py → llm_param.py} +12 -32
- klaude_code/protocol/model.py +49 -4
- klaude_code/protocol/op.py +18 -16
- klaude_code/protocol/op_handler.py +28 -0
- klaude_code/{core → protocol}/sub_agent.py +7 -0
- klaude_code/session/export.py +150 -70
- klaude_code/session/session.py +28 -14
- klaude_code/session/templates/export_session.html +180 -42
- klaude_code/trace/__init__.py +2 -2
- klaude_code/trace/log.py +11 -5
- klaude_code/ui/__init__.py +91 -8
- klaude_code/ui/core/__init__.py +1 -0
- klaude_code/ui/core/display.py +103 -0
- klaude_code/ui/core/input.py +71 -0
- klaude_code/ui/modes/__init__.py +1 -0
- klaude_code/ui/modes/debug/__init__.py +1 -0
- klaude_code/ui/{base/debug_event_display.py → modes/debug/display.py} +9 -5
- klaude_code/ui/modes/exec/__init__.py +1 -0
- klaude_code/ui/{base/exec_display.py → modes/exec/display.py} +28 -2
- klaude_code/ui/{repl → modes/repl}/__init__.py +5 -6
- klaude_code/ui/modes/repl/clipboard.py +152 -0
- klaude_code/ui/modes/repl/completers.py +429 -0
- klaude_code/ui/modes/repl/display.py +60 -0
- klaude_code/ui/modes/repl/event_handler.py +375 -0
- klaude_code/ui/modes/repl/input_prompt_toolkit.py +198 -0
- klaude_code/ui/modes/repl/key_bindings.py +170 -0
- klaude_code/ui/{repl → modes/repl}/renderer.py +109 -132
- klaude_code/ui/renderers/assistant.py +21 -0
- klaude_code/ui/renderers/common.py +0 -16
- klaude_code/ui/renderers/developer.py +18 -18
- klaude_code/ui/renderers/diffs.py +36 -14
- klaude_code/ui/renderers/errors.py +1 -1
- klaude_code/ui/renderers/metadata.py +50 -27
- klaude_code/ui/renderers/sub_agent.py +43 -9
- klaude_code/ui/renderers/thinking.py +33 -1
- klaude_code/ui/renderers/tools.py +212 -20
- klaude_code/ui/renderers/user_input.py +19 -23
- klaude_code/ui/rich/__init__.py +1 -0
- klaude_code/ui/{rich_ext → rich}/searchable_text.py +3 -1
- klaude_code/ui/{renderers → rich}/status.py +29 -18
- klaude_code/ui/{base → rich}/theme.py +8 -2
- klaude_code/ui/terminal/__init__.py +1 -0
- klaude_code/ui/{base/terminal_color.py → terminal/color.py} +4 -1
- klaude_code/ui/{base/terminal_control.py → terminal/control.py} +1 -0
- klaude_code/ui/{base/terminal_notifier.py → terminal/notifier.py} +5 -2
- klaude_code/ui/utils/__init__.py +1 -0
- klaude_code/ui/{base/utils.py → utils/common.py} +35 -3
- {klaude_code-1.2.1.dist-info → klaude_code-1.2.3.dist-info}/METADATA +1 -1
- klaude_code-1.2.3.dist-info/RECORD +161 -0
- klaude_code/core/clipboard_manifest.py +0 -124
- klaude_code/llm/openrouter/tool_call_accumulator.py +0 -80
- klaude_code/ui/base/__init__.py +0 -1
- klaude_code/ui/base/display_abc.py +0 -36
- klaude_code/ui/base/input_abc.py +0 -20
- klaude_code/ui/repl/display.py +0 -36
- klaude_code/ui/repl/event_handler.py +0 -247
- klaude_code/ui/repl/input.py +0 -773
- klaude_code/ui/rich_ext/__init__.py +0 -1
- klaude_code-1.2.1.dist-info/RECORD +0 -151
- /klaude_code/core/{prompt → prompts}/prompt-claude-code.md +0 -0
- /klaude_code/core/{prompt → prompts}/prompt-codex.md +0 -0
- /klaude_code/core/{prompt → prompts}/prompt-subagent-explore.md +0 -0
- /klaude_code/core/{prompt → prompts}/prompt-subagent-oracle.md +0 -0
- /klaude_code/core/{prompt → prompts}/prompt-subagent-webfetch.md +0 -0
- /klaude_code/core/{prompt → prompts}/prompt-subagent.md +0 -0
- /klaude_code/ui/{base → core}/stage_manager.py +0 -0
- /klaude_code/ui/{rich_ext → rich}/live.py +0 -0
- /klaude_code/ui/{rich_ext → rich}/markdown.py +0 -0
- /klaude_code/ui/{rich_ext → rich}/quote.py +0 -0
- /klaude_code/ui/{base → terminal}/progress_bar.py +0 -0
- /klaude_code/ui/{base → utils}/debouncer.py +0 -0
- {klaude_code-1.2.1.dist-info → klaude_code-1.2.3.dist-info}/WHEEL +0 -0
- {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
|