code-puppy 0.0.97__py3-none-any.whl → 0.0.119__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.
- code_puppy/__init__.py +2 -5
- code_puppy/__main__.py +10 -0
- code_puppy/agent.py +125 -40
- code_puppy/agent_prompts.py +30 -24
- code_puppy/callbacks.py +152 -0
- code_puppy/command_line/command_handler.py +359 -0
- code_puppy/command_line/load_context_completion.py +59 -0
- code_puppy/command_line/model_picker_completion.py +14 -21
- code_puppy/command_line/motd.py +44 -28
- code_puppy/command_line/prompt_toolkit_completion.py +42 -23
- code_puppy/config.py +266 -26
- code_puppy/http_utils.py +122 -0
- code_puppy/main.py +570 -383
- code_puppy/message_history_processor.py +195 -104
- code_puppy/messaging/__init__.py +46 -0
- code_puppy/messaging/message_queue.py +288 -0
- code_puppy/messaging/queue_console.py +293 -0
- code_puppy/messaging/renderers.py +305 -0
- code_puppy/messaging/spinner/__init__.py +55 -0
- code_puppy/messaging/spinner/console_spinner.py +200 -0
- code_puppy/messaging/spinner/spinner_base.py +66 -0
- code_puppy/messaging/spinner/textual_spinner.py +97 -0
- code_puppy/model_factory.py +73 -105
- code_puppy/plugins/__init__.py +32 -0
- code_puppy/reopenable_async_client.py +225 -0
- code_puppy/state_management.py +60 -21
- code_puppy/summarization_agent.py +56 -35
- code_puppy/token_utils.py +7 -9
- code_puppy/tools/__init__.py +1 -4
- code_puppy/tools/command_runner.py +187 -32
- code_puppy/tools/common.py +44 -35
- code_puppy/tools/file_modifications.py +335 -118
- code_puppy/tools/file_operations.py +368 -95
- code_puppy/tools/token_check.py +27 -11
- code_puppy/tools/tools_content.py +53 -0
- code_puppy/tui/__init__.py +10 -0
- code_puppy/tui/app.py +1050 -0
- code_puppy/tui/components/__init__.py +21 -0
- code_puppy/tui/components/chat_view.py +512 -0
- code_puppy/tui/components/command_history_modal.py +218 -0
- code_puppy/tui/components/copy_button.py +139 -0
- code_puppy/tui/components/custom_widgets.py +58 -0
- code_puppy/tui/components/input_area.py +167 -0
- code_puppy/tui/components/sidebar.py +309 -0
- code_puppy/tui/components/status_bar.py +182 -0
- code_puppy/tui/messages.py +27 -0
- code_puppy/tui/models/__init__.py +8 -0
- code_puppy/tui/models/chat_message.py +25 -0
- code_puppy/tui/models/command_history.py +89 -0
- code_puppy/tui/models/enums.py +24 -0
- code_puppy/tui/screens/__init__.py +13 -0
- code_puppy/tui/screens/help.py +130 -0
- code_puppy/tui/screens/settings.py +255 -0
- code_puppy/tui/screens/tools.py +74 -0
- code_puppy/tui/tests/__init__.py +1 -0
- code_puppy/tui/tests/test_chat_message.py +28 -0
- code_puppy/tui/tests/test_chat_view.py +88 -0
- code_puppy/tui/tests/test_command_history.py +89 -0
- code_puppy/tui/tests/test_copy_button.py +191 -0
- code_puppy/tui/tests/test_custom_widgets.py +27 -0
- code_puppy/tui/tests/test_disclaimer.py +27 -0
- code_puppy/tui/tests/test_enums.py +15 -0
- code_puppy/tui/tests/test_file_browser.py +60 -0
- code_puppy/tui/tests/test_help.py +38 -0
- code_puppy/tui/tests/test_history_file_reader.py +107 -0
- code_puppy/tui/tests/test_input_area.py +33 -0
- code_puppy/tui/tests/test_settings.py +44 -0
- code_puppy/tui/tests/test_sidebar.py +33 -0
- code_puppy/tui/tests/test_sidebar_history.py +153 -0
- code_puppy/tui/tests/test_sidebar_history_navigation.py +132 -0
- code_puppy/tui/tests/test_status_bar.py +54 -0
- code_puppy/tui/tests/test_timestamped_history.py +52 -0
- code_puppy/tui/tests/test_tools.py +82 -0
- code_puppy/version_checker.py +26 -3
- {code_puppy-0.0.97.dist-info → code_puppy-0.0.119.dist-info}/METADATA +9 -2
- code_puppy-0.0.119.dist-info/RECORD +86 -0
- code_puppy-0.0.97.dist-info/RECORD +0 -32
- {code_puppy-0.0.97.data → code_puppy-0.0.119.data}/data/code_puppy/models.json +0 -0
- {code_puppy-0.0.97.dist-info → code_puppy-0.0.119.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.97.dist-info → code_puppy-0.0.119.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.97.dist-info → code_puppy-0.0.119.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Renderer implementations for different UI modes.
|
|
3
|
+
|
|
4
|
+
These renderers consume messages from the queue and display them
|
|
5
|
+
appropriately for their respective interfaces.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import threading
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from io import StringIO
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.markdown import Markdown
|
|
16
|
+
|
|
17
|
+
from .message_queue import MessageQueue, MessageType, UIMessage
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MessageRenderer(ABC):
|
|
21
|
+
"""Base class for message renderers."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, queue: MessageQueue):
|
|
24
|
+
self.queue = queue
|
|
25
|
+
self._running = False
|
|
26
|
+
self._task = None
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
async def render_message(self, message: UIMessage):
|
|
30
|
+
"""Render a single message."""
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
async def start(self):
|
|
34
|
+
"""Start the renderer."""
|
|
35
|
+
if self._running:
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
self._running = True
|
|
39
|
+
# Mark the queue as having an active renderer
|
|
40
|
+
self.queue.mark_renderer_active()
|
|
41
|
+
self._task = asyncio.create_task(self._consume_messages())
|
|
42
|
+
|
|
43
|
+
async def stop(self):
|
|
44
|
+
"""Stop the renderer."""
|
|
45
|
+
self._running = False
|
|
46
|
+
# Mark the queue as having no active renderer
|
|
47
|
+
self.queue.mark_renderer_inactive()
|
|
48
|
+
if self._task:
|
|
49
|
+
self._task.cancel()
|
|
50
|
+
try:
|
|
51
|
+
await self._task
|
|
52
|
+
except asyncio.CancelledError:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
async def _consume_messages(self):
|
|
56
|
+
"""Consume messages from the queue."""
|
|
57
|
+
while self._running:
|
|
58
|
+
try:
|
|
59
|
+
message = await asyncio.wait_for(self.queue.get_async(), timeout=0.1)
|
|
60
|
+
await self.render_message(message)
|
|
61
|
+
except asyncio.TimeoutError:
|
|
62
|
+
continue
|
|
63
|
+
except asyncio.CancelledError:
|
|
64
|
+
break
|
|
65
|
+
except Exception as e:
|
|
66
|
+
# Log error but continue processing
|
|
67
|
+
print(f"Error rendering message: {e}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class InteractiveRenderer(MessageRenderer):
|
|
71
|
+
"""Renderer for interactive CLI mode using Rich console.
|
|
72
|
+
|
|
73
|
+
Note: This async-based renderer is not currently used in the codebase.
|
|
74
|
+
Interactive mode currently uses SynchronousInteractiveRenderer instead.
|
|
75
|
+
A future refactoring might consolidate these renderers.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, queue: MessageQueue, console: Optional[Console] = None):
|
|
79
|
+
super().__init__(queue)
|
|
80
|
+
self.console = console or Console()
|
|
81
|
+
|
|
82
|
+
async def render_message(self, message: UIMessage):
|
|
83
|
+
"""Render a message using Rich console."""
|
|
84
|
+
# Convert message type to appropriate Rich styling
|
|
85
|
+
if message.type == MessageType.ERROR:
|
|
86
|
+
style = "bold red"
|
|
87
|
+
elif message.type == MessageType.WARNING:
|
|
88
|
+
style = "yellow"
|
|
89
|
+
elif message.type == MessageType.SUCCESS:
|
|
90
|
+
style = "green"
|
|
91
|
+
elif message.type == MessageType.TOOL_OUTPUT:
|
|
92
|
+
style = "blue"
|
|
93
|
+
elif message.type == MessageType.AGENT_REASONING:
|
|
94
|
+
style = None
|
|
95
|
+
elif message.type == MessageType.PLANNED_NEXT_STEPS:
|
|
96
|
+
style = None
|
|
97
|
+
elif message.type == MessageType.AGENT_RESPONSE:
|
|
98
|
+
# Special handling for agent responses - they'll be rendered as markdown
|
|
99
|
+
style = None
|
|
100
|
+
elif message.type == MessageType.SYSTEM:
|
|
101
|
+
style = None
|
|
102
|
+
else:
|
|
103
|
+
style = None
|
|
104
|
+
|
|
105
|
+
# Render the content
|
|
106
|
+
if isinstance(message.content, str):
|
|
107
|
+
if message.type == MessageType.AGENT_RESPONSE:
|
|
108
|
+
# Render agent responses as markdown
|
|
109
|
+
try:
|
|
110
|
+
markdown = Markdown(message.content)
|
|
111
|
+
self.console.print(markdown)
|
|
112
|
+
except Exception:
|
|
113
|
+
# Fallback to plain text if markdown parsing fails
|
|
114
|
+
self.console.print(message.content)
|
|
115
|
+
elif style:
|
|
116
|
+
self.console.print(message.content, style=style)
|
|
117
|
+
else:
|
|
118
|
+
self.console.print(message.content)
|
|
119
|
+
else:
|
|
120
|
+
# For complex Rich objects (Tables, Markdown, Text, etc.)
|
|
121
|
+
self.console.print(message.content)
|
|
122
|
+
|
|
123
|
+
# Ensure output is immediately flushed to the terminal
|
|
124
|
+
# This fixes the issue where messages don't appear until user input
|
|
125
|
+
if hasattr(self.console.file, "flush"):
|
|
126
|
+
self.console.file.flush()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class TUIRenderer(MessageRenderer):
|
|
130
|
+
"""Renderer for TUI mode that adds messages to the chat view."""
|
|
131
|
+
|
|
132
|
+
def __init__(self, queue: MessageQueue, tui_app=None):
|
|
133
|
+
super().__init__(queue)
|
|
134
|
+
self.tui_app = tui_app
|
|
135
|
+
|
|
136
|
+
def set_tui_app(self, app):
|
|
137
|
+
"""Set the TUI app reference."""
|
|
138
|
+
self.tui_app = app
|
|
139
|
+
|
|
140
|
+
async def render_message(self, message: UIMessage):
|
|
141
|
+
"""Render a message in the TUI chat view."""
|
|
142
|
+
if not self.tui_app:
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
# Extract group_id from message metadata (fixing the key name)
|
|
146
|
+
group_id = message.metadata.get("message_group") if message.metadata else None
|
|
147
|
+
|
|
148
|
+
# For INFO messages with Rich objects (like Markdown), preserve them for proper rendering
|
|
149
|
+
if message.type == MessageType.INFO and hasattr(
|
|
150
|
+
message.content, "__rich_console__"
|
|
151
|
+
):
|
|
152
|
+
# Pass the Rich object directly to maintain markdown formatting
|
|
153
|
+
self.tui_app.add_system_message_rich(
|
|
154
|
+
message.content, message_group=group_id
|
|
155
|
+
)
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
# Convert content to string for TUI display (for all other cases)
|
|
159
|
+
if hasattr(message.content, "__rich_console__"):
|
|
160
|
+
# For Rich objects, render to plain text using a Console
|
|
161
|
+
string_io = StringIO()
|
|
162
|
+
# Use markup=False to prevent interpretation of square brackets as markup
|
|
163
|
+
temp_console = Console(
|
|
164
|
+
file=string_io, width=80, legacy_windows=False, markup=False
|
|
165
|
+
)
|
|
166
|
+
temp_console.print(message.content)
|
|
167
|
+
content_str = string_io.getvalue().rstrip("\n")
|
|
168
|
+
else:
|
|
169
|
+
content_str = str(message.content)
|
|
170
|
+
|
|
171
|
+
# Map message types to TUI message types - ALL get group_id now
|
|
172
|
+
if message.type in (MessageType.ERROR,):
|
|
173
|
+
self.tui_app.add_error_message(content_str, message_group=group_id)
|
|
174
|
+
elif message.type in (
|
|
175
|
+
MessageType.SYSTEM,
|
|
176
|
+
MessageType.INFO,
|
|
177
|
+
MessageType.WARNING,
|
|
178
|
+
MessageType.SUCCESS,
|
|
179
|
+
):
|
|
180
|
+
self.tui_app.add_system_message(content_str, message_group=group_id)
|
|
181
|
+
elif message.type == MessageType.AGENT_REASONING:
|
|
182
|
+
# Agent reasoning messages should use the dedicated method
|
|
183
|
+
self.tui_app.add_agent_reasoning_message(
|
|
184
|
+
content_str, message_group=group_id
|
|
185
|
+
)
|
|
186
|
+
elif message.type == MessageType.PLANNED_NEXT_STEPS:
|
|
187
|
+
# Agent reasoning messages should use the dedicated method
|
|
188
|
+
self.tui_app.add_planned_next_steps_message(
|
|
189
|
+
content_str, message_group=group_id
|
|
190
|
+
)
|
|
191
|
+
elif message.type in (
|
|
192
|
+
MessageType.TOOL_OUTPUT,
|
|
193
|
+
MessageType.COMMAND_OUTPUT,
|
|
194
|
+
MessageType.AGENT_RESPONSE,
|
|
195
|
+
):
|
|
196
|
+
# These are typically agent/tool outputs
|
|
197
|
+
self.tui_app.add_agent_message(content_str, message_group=group_id)
|
|
198
|
+
else:
|
|
199
|
+
# Default to system message
|
|
200
|
+
self.tui_app.add_system_message(content_str, message_group=group_id)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class SynchronousInteractiveRenderer:
|
|
204
|
+
"""
|
|
205
|
+
Synchronous renderer for interactive mode that doesn't require async.
|
|
206
|
+
|
|
207
|
+
This is useful for cases where we want immediate rendering without
|
|
208
|
+
the overhead of async message processing.
|
|
209
|
+
|
|
210
|
+
Note: As part of the messaging system refactoring, we're keeping this class for now
|
|
211
|
+
as it's essential for the interactive mode to function properly. Future refactoring
|
|
212
|
+
could replace this with a simpler implementation that leverages the unified message
|
|
213
|
+
queue system more effectively, or potentially convert interactive mode to use
|
|
214
|
+
async/await consistently and use InteractiveRenderer instead.
|
|
215
|
+
|
|
216
|
+
Current responsibilities:
|
|
217
|
+
- Consumes messages from the queue in a background thread
|
|
218
|
+
- Renders messages to the console in real-time without requiring async code
|
|
219
|
+
- Registers as a direct listener to the message queue for immediate processing
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
def __init__(self, queue: MessageQueue, console: Optional[Console] = None):
|
|
223
|
+
self.queue = queue
|
|
224
|
+
self.console = console or Console()
|
|
225
|
+
self._running = False
|
|
226
|
+
self._thread = None
|
|
227
|
+
|
|
228
|
+
def start(self):
|
|
229
|
+
"""Start the synchronous renderer in a background thread."""
|
|
230
|
+
if self._running:
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
self._running = True
|
|
234
|
+
# Mark the queue as having an active renderer
|
|
235
|
+
self.queue.mark_renderer_active()
|
|
236
|
+
# Add ourselves as a listener for immediate processing
|
|
237
|
+
self.queue.add_listener(self._render_message)
|
|
238
|
+
self._thread = threading.Thread(target=self._consume_messages, daemon=True)
|
|
239
|
+
self._thread.start()
|
|
240
|
+
|
|
241
|
+
def stop(self):
|
|
242
|
+
"""Stop the synchronous renderer."""
|
|
243
|
+
self._running = False
|
|
244
|
+
# Mark the queue as having no active renderer
|
|
245
|
+
self.queue.mark_renderer_inactive()
|
|
246
|
+
# Remove ourselves as a listener
|
|
247
|
+
self.queue.remove_listener(self._render_message)
|
|
248
|
+
if self._thread and self._thread.is_alive():
|
|
249
|
+
self._thread.join(timeout=1.0)
|
|
250
|
+
|
|
251
|
+
def _consume_messages(self):
|
|
252
|
+
"""Consume messages synchronously."""
|
|
253
|
+
while self._running:
|
|
254
|
+
message = self.queue.get_nowait()
|
|
255
|
+
if message:
|
|
256
|
+
self._render_message(message)
|
|
257
|
+
else:
|
|
258
|
+
# No messages, sleep briefly
|
|
259
|
+
import time
|
|
260
|
+
|
|
261
|
+
time.sleep(0.01)
|
|
262
|
+
|
|
263
|
+
def _render_message(self, message: UIMessage):
|
|
264
|
+
"""Render a message using Rich console."""
|
|
265
|
+
# Convert message type to appropriate Rich styling
|
|
266
|
+
if message.type == MessageType.ERROR:
|
|
267
|
+
style = "bold red"
|
|
268
|
+
elif message.type == MessageType.WARNING:
|
|
269
|
+
style = "yellow"
|
|
270
|
+
elif message.type == MessageType.SUCCESS:
|
|
271
|
+
style = "green"
|
|
272
|
+
elif message.type == MessageType.TOOL_OUTPUT:
|
|
273
|
+
style = "blue"
|
|
274
|
+
elif message.type == MessageType.AGENT_REASONING:
|
|
275
|
+
style = None
|
|
276
|
+
elif message.type == MessageType.AGENT_RESPONSE:
|
|
277
|
+
# Special handling for agent responses - they'll be rendered as markdown
|
|
278
|
+
style = None
|
|
279
|
+
elif message.type == MessageType.SYSTEM:
|
|
280
|
+
style = None
|
|
281
|
+
else:
|
|
282
|
+
style = None
|
|
283
|
+
|
|
284
|
+
# Render the content
|
|
285
|
+
if isinstance(message.content, str):
|
|
286
|
+
if message.type == MessageType.AGENT_RESPONSE:
|
|
287
|
+
# Render agent responses as markdown
|
|
288
|
+
try:
|
|
289
|
+
markdown = Markdown(message.content)
|
|
290
|
+
self.console.print(markdown)
|
|
291
|
+
except Exception:
|
|
292
|
+
# Fallback to plain text if markdown parsing fails
|
|
293
|
+
self.console.print(message.content)
|
|
294
|
+
elif style:
|
|
295
|
+
self.console.print(message.content, style=style)
|
|
296
|
+
else:
|
|
297
|
+
self.console.print(message.content)
|
|
298
|
+
else:
|
|
299
|
+
# For complex Rich objects (Tables, Markdown, Text, etc.)
|
|
300
|
+
self.console.print(message.content)
|
|
301
|
+
|
|
302
|
+
# Ensure output is immediately flushed to the terminal
|
|
303
|
+
# This fixes the issue where messages don't appear until user input
|
|
304
|
+
if hasattr(self.console.file, "flush"):
|
|
305
|
+
self.console.file.flush()
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared spinner implementation for both TUI and CLI modes.
|
|
3
|
+
|
|
4
|
+
This module provides consistent spinner animations across different UI modes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .console_spinner import ConsoleSpinner
|
|
8
|
+
from .spinner_base import SpinnerBase
|
|
9
|
+
from .textual_spinner import TextualSpinner
|
|
10
|
+
|
|
11
|
+
# Keep track of all active spinners to manage them globally
|
|
12
|
+
_active_spinners = []
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def register_spinner(spinner):
|
|
16
|
+
"""Register an active spinner to be managed globally."""
|
|
17
|
+
if spinner not in _active_spinners:
|
|
18
|
+
_active_spinners.append(spinner)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def unregister_spinner(spinner):
|
|
22
|
+
"""Remove a spinner from global management."""
|
|
23
|
+
if spinner in _active_spinners:
|
|
24
|
+
_active_spinners.remove(spinner)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def pause_all_spinners():
|
|
28
|
+
"""Pause all active spinners."""
|
|
29
|
+
for spinner in _active_spinners:
|
|
30
|
+
try:
|
|
31
|
+
spinner.pause()
|
|
32
|
+
except Exception:
|
|
33
|
+
# Ignore errors if a spinner can't be paused
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def resume_all_spinners():
|
|
38
|
+
"""Resume all active spinners."""
|
|
39
|
+
for spinner in _active_spinners:
|
|
40
|
+
try:
|
|
41
|
+
spinner.resume()
|
|
42
|
+
except Exception:
|
|
43
|
+
# Ignore errors if a spinner can't be resumed
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"SpinnerBase",
|
|
49
|
+
"TextualSpinner",
|
|
50
|
+
"ConsoleSpinner",
|
|
51
|
+
"register_spinner",
|
|
52
|
+
"unregister_spinner",
|
|
53
|
+
"pause_all_spinners",
|
|
54
|
+
"resume_all_spinners",
|
|
55
|
+
]
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Console spinner implementation for CLI mode using Rich's Live Display.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.live import Live
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
from .spinner_base import SpinnerBase
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConsoleSpinner(SpinnerBase):
|
|
16
|
+
"""A console-based spinner implementation using Rich's Live Display."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, console=None):
|
|
19
|
+
"""Initialize the console spinner.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
console: Optional Rich console instance to use for output.
|
|
23
|
+
If not provided, a new one will be created.
|
|
24
|
+
"""
|
|
25
|
+
super().__init__()
|
|
26
|
+
self.console = console or Console()
|
|
27
|
+
self._thread = None
|
|
28
|
+
self._stop_event = threading.Event()
|
|
29
|
+
self._paused = False
|
|
30
|
+
self._live = None
|
|
31
|
+
|
|
32
|
+
# Register this spinner for global management
|
|
33
|
+
from . import register_spinner
|
|
34
|
+
|
|
35
|
+
register_spinner(self)
|
|
36
|
+
|
|
37
|
+
def start(self):
|
|
38
|
+
"""Start the spinner animation."""
|
|
39
|
+
super().start()
|
|
40
|
+
self._stop_event.clear()
|
|
41
|
+
|
|
42
|
+
# Don't start a new thread if one is already running
|
|
43
|
+
if self._thread and self._thread.is_alive():
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
# Create a Live display for the spinner
|
|
47
|
+
self._live = Live(
|
|
48
|
+
self._generate_spinner_panel(),
|
|
49
|
+
console=self.console,
|
|
50
|
+
refresh_per_second=10,
|
|
51
|
+
transient=True,
|
|
52
|
+
auto_refresh=False, # Don't auto-refresh to avoid wiping out user input
|
|
53
|
+
)
|
|
54
|
+
self._live.start()
|
|
55
|
+
|
|
56
|
+
# Start a thread to update the spinner frames
|
|
57
|
+
self._thread = threading.Thread(target=self._update_spinner)
|
|
58
|
+
self._thread.daemon = True
|
|
59
|
+
self._thread.start()
|
|
60
|
+
|
|
61
|
+
def stop(self):
|
|
62
|
+
"""Stop the spinner animation."""
|
|
63
|
+
if not self._is_spinning:
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
self._stop_event.set()
|
|
67
|
+
self._is_spinning = False
|
|
68
|
+
|
|
69
|
+
if self._live:
|
|
70
|
+
self._live.stop()
|
|
71
|
+
self._live = None
|
|
72
|
+
|
|
73
|
+
if self._thread and self._thread.is_alive():
|
|
74
|
+
self._thread.join(timeout=0.5)
|
|
75
|
+
|
|
76
|
+
self._thread = None
|
|
77
|
+
|
|
78
|
+
# Unregister this spinner from global management
|
|
79
|
+
from . import unregister_spinner
|
|
80
|
+
|
|
81
|
+
unregister_spinner(self)
|
|
82
|
+
|
|
83
|
+
def update_frame(self):
|
|
84
|
+
"""Update to the next frame."""
|
|
85
|
+
super().update_frame()
|
|
86
|
+
|
|
87
|
+
def _generate_spinner_panel(self):
|
|
88
|
+
"""Generate a Rich panel containing the spinner text."""
|
|
89
|
+
if self._paused:
|
|
90
|
+
return Text("")
|
|
91
|
+
|
|
92
|
+
text = Text()
|
|
93
|
+
|
|
94
|
+
# Check if we're awaiting user input to determine which message to show
|
|
95
|
+
from code_puppy.tools.command_runner import is_awaiting_user_input
|
|
96
|
+
|
|
97
|
+
if is_awaiting_user_input():
|
|
98
|
+
# Show waiting message when waiting for user input
|
|
99
|
+
text.append(SpinnerBase.WAITING_MESSAGE, style="bold cyan")
|
|
100
|
+
else:
|
|
101
|
+
# Show thinking message during normal processing
|
|
102
|
+
text.append(SpinnerBase.THINKING_MESSAGE, style="bold cyan")
|
|
103
|
+
|
|
104
|
+
text.append(self.current_frame, style="bold cyan")
|
|
105
|
+
|
|
106
|
+
# Return a simple Text object instead of a Panel for a cleaner look
|
|
107
|
+
return text
|
|
108
|
+
|
|
109
|
+
def _update_spinner(self):
|
|
110
|
+
"""Update the spinner in a background thread."""
|
|
111
|
+
try:
|
|
112
|
+
while not self._stop_event.is_set():
|
|
113
|
+
# Update the frame
|
|
114
|
+
self.update_frame()
|
|
115
|
+
|
|
116
|
+
# Check if we're awaiting user input before updating the display
|
|
117
|
+
from code_puppy.tools.command_runner import is_awaiting_user_input
|
|
118
|
+
|
|
119
|
+
awaiting_input = is_awaiting_user_input()
|
|
120
|
+
|
|
121
|
+
# Update the live display only if not paused and not awaiting input
|
|
122
|
+
if self._live and not self._paused and not awaiting_input:
|
|
123
|
+
# Manually refresh instead of auto-refresh to avoid wiping input
|
|
124
|
+
self._live.update(self._generate_spinner_panel())
|
|
125
|
+
self._live.refresh()
|
|
126
|
+
|
|
127
|
+
# Short sleep to control animation speed
|
|
128
|
+
time.sleep(0.1)
|
|
129
|
+
except Exception as e:
|
|
130
|
+
print(f"\nSpinner error: {e}")
|
|
131
|
+
self._is_spinning = False
|
|
132
|
+
|
|
133
|
+
def pause(self):
|
|
134
|
+
"""Pause the spinner animation."""
|
|
135
|
+
if self._is_spinning:
|
|
136
|
+
self._paused = True
|
|
137
|
+
# Update the live display to hide the spinner immediately
|
|
138
|
+
if self._live:
|
|
139
|
+
try:
|
|
140
|
+
# When pausing, first update with the waiting message
|
|
141
|
+
# so it's visible briefly before disappearing
|
|
142
|
+
from code_puppy.tools.command_runner import is_awaiting_user_input
|
|
143
|
+
|
|
144
|
+
if is_awaiting_user_input():
|
|
145
|
+
text = Text()
|
|
146
|
+
text.append(SpinnerBase.WAITING_MESSAGE, style="bold cyan")
|
|
147
|
+
text.append(self.current_frame, style="bold cyan")
|
|
148
|
+
self._live.update(text)
|
|
149
|
+
self._live.refresh()
|
|
150
|
+
# Allow a moment for the waiting message to be visible
|
|
151
|
+
import time
|
|
152
|
+
|
|
153
|
+
time.sleep(0.1)
|
|
154
|
+
|
|
155
|
+
# Then clear the display
|
|
156
|
+
self._live.update(Text(""))
|
|
157
|
+
except Exception:
|
|
158
|
+
# If update fails, try stopping it completely
|
|
159
|
+
try:
|
|
160
|
+
self._live.stop()
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
def resume(self):
|
|
165
|
+
"""Resume the spinner animation."""
|
|
166
|
+
# Check if we should show a spinner - don't resume if waiting for user input
|
|
167
|
+
from code_puppy.tools.command_runner import is_awaiting_user_input
|
|
168
|
+
|
|
169
|
+
if is_awaiting_user_input():
|
|
170
|
+
return # Don't resume if waiting for user input
|
|
171
|
+
|
|
172
|
+
if self._is_spinning and self._paused:
|
|
173
|
+
self._paused = False
|
|
174
|
+
# Force an immediate update to show the spinner again
|
|
175
|
+
if self._live:
|
|
176
|
+
try:
|
|
177
|
+
self._live.update(self._generate_spinner_panel())
|
|
178
|
+
except Exception:
|
|
179
|
+
# If update fails, the live display might have been stopped
|
|
180
|
+
# Try to restart it
|
|
181
|
+
try:
|
|
182
|
+
self._live = Live(
|
|
183
|
+
self._generate_spinner_panel(),
|
|
184
|
+
console=self.console,
|
|
185
|
+
refresh_per_second=10,
|
|
186
|
+
transient=True,
|
|
187
|
+
auto_refresh=False, # Don't auto-refresh to avoid wiping out user input
|
|
188
|
+
)
|
|
189
|
+
self._live.start()
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
def __enter__(self):
|
|
194
|
+
"""Support for context manager."""
|
|
195
|
+
self.start()
|
|
196
|
+
return self
|
|
197
|
+
|
|
198
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
199
|
+
"""Clean up when exiting context manager."""
|
|
200
|
+
self.stop()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base spinner implementation to be extended for different UI modes.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
from code_puppy.config import get_puppy_name
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SpinnerBase(ABC):
|
|
11
|
+
"""Abstract base class for spinner implementations."""
|
|
12
|
+
|
|
13
|
+
# Shared spinner frames across implementations
|
|
14
|
+
FRAMES = [
|
|
15
|
+
"(🐶 ) ",
|
|
16
|
+
"( 🐶 ) ",
|
|
17
|
+
"( 🐶 ) ",
|
|
18
|
+
"( 🐶 ) ",
|
|
19
|
+
"( 🐶) ",
|
|
20
|
+
"( 🐶 ) ",
|
|
21
|
+
"( 🐶 ) ",
|
|
22
|
+
"( 🐶 ) ",
|
|
23
|
+
"(🐶 ) ",
|
|
24
|
+
]
|
|
25
|
+
puppy_name = get_puppy_name().title()
|
|
26
|
+
|
|
27
|
+
# Default message when processing
|
|
28
|
+
THINKING_MESSAGE = f"{puppy_name} is thinking... "
|
|
29
|
+
|
|
30
|
+
# Message when waiting for user input
|
|
31
|
+
WAITING_MESSAGE = f"{puppy_name} is waiting... "
|
|
32
|
+
|
|
33
|
+
# Current message - starts with thinking by default
|
|
34
|
+
MESSAGE = THINKING_MESSAGE
|
|
35
|
+
|
|
36
|
+
def __init__(self):
|
|
37
|
+
"""Initialize the spinner."""
|
|
38
|
+
self._is_spinning = False
|
|
39
|
+
self._frame_index = 0
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def start(self):
|
|
43
|
+
"""Start the spinner animation."""
|
|
44
|
+
self._is_spinning = True
|
|
45
|
+
self._frame_index = 0
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def stop(self):
|
|
49
|
+
"""Stop the spinner animation."""
|
|
50
|
+
self._is_spinning = False
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def update_frame(self):
|
|
54
|
+
"""Update to the next frame."""
|
|
55
|
+
if self._is_spinning:
|
|
56
|
+
self._frame_index = (self._frame_index + 1) % len(self.FRAMES)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def current_frame(self):
|
|
60
|
+
"""Get the current frame."""
|
|
61
|
+
return self.FRAMES[self._frame_index]
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def is_spinning(self):
|
|
65
|
+
"""Check if the spinner is currently spinning."""
|
|
66
|
+
return self._is_spinning
|