code-puppy 0.0.97__py3-none-any.whl → 0.0.118__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 (81) hide show
  1. code_puppy/__init__.py +2 -5
  2. code_puppy/__main__.py +10 -0
  3. code_puppy/agent.py +125 -40
  4. code_puppy/agent_prompts.py +30 -24
  5. code_puppy/callbacks.py +152 -0
  6. code_puppy/command_line/command_handler.py +359 -0
  7. code_puppy/command_line/load_context_completion.py +59 -0
  8. code_puppy/command_line/model_picker_completion.py +14 -21
  9. code_puppy/command_line/motd.py +44 -28
  10. code_puppy/command_line/prompt_toolkit_completion.py +42 -23
  11. code_puppy/config.py +266 -26
  12. code_puppy/http_utils.py +122 -0
  13. code_puppy/main.py +570 -383
  14. code_puppy/message_history_processor.py +195 -104
  15. code_puppy/messaging/__init__.py +46 -0
  16. code_puppy/messaging/message_queue.py +288 -0
  17. code_puppy/messaging/queue_console.py +293 -0
  18. code_puppy/messaging/renderers.py +305 -0
  19. code_puppy/messaging/spinner/__init__.py +55 -0
  20. code_puppy/messaging/spinner/console_spinner.py +200 -0
  21. code_puppy/messaging/spinner/spinner_base.py +66 -0
  22. code_puppy/messaging/spinner/textual_spinner.py +97 -0
  23. code_puppy/model_factory.py +73 -105
  24. code_puppy/plugins/__init__.py +32 -0
  25. code_puppy/reopenable_async_client.py +225 -0
  26. code_puppy/state_management.py +60 -21
  27. code_puppy/summarization_agent.py +56 -35
  28. code_puppy/token_utils.py +7 -9
  29. code_puppy/tools/__init__.py +1 -4
  30. code_puppy/tools/command_runner.py +187 -32
  31. code_puppy/tools/common.py +44 -35
  32. code_puppy/tools/file_modifications.py +335 -118
  33. code_puppy/tools/file_operations.py +368 -95
  34. code_puppy/tools/token_check.py +27 -11
  35. code_puppy/tools/tools_content.py +53 -0
  36. code_puppy/tui/__init__.py +10 -0
  37. code_puppy/tui/app.py +1050 -0
  38. code_puppy/tui/components/__init__.py +21 -0
  39. code_puppy/tui/components/chat_view.py +512 -0
  40. code_puppy/tui/components/command_history_modal.py +218 -0
  41. code_puppy/tui/components/copy_button.py +139 -0
  42. code_puppy/tui/components/custom_widgets.py +58 -0
  43. code_puppy/tui/components/input_area.py +167 -0
  44. code_puppy/tui/components/sidebar.py +309 -0
  45. code_puppy/tui/components/status_bar.py +182 -0
  46. code_puppy/tui/messages.py +27 -0
  47. code_puppy/tui/models/__init__.py +8 -0
  48. code_puppy/tui/models/chat_message.py +25 -0
  49. code_puppy/tui/models/command_history.py +89 -0
  50. code_puppy/tui/models/enums.py +24 -0
  51. code_puppy/tui/screens/__init__.py +13 -0
  52. code_puppy/tui/screens/help.py +130 -0
  53. code_puppy/tui/screens/settings.py +256 -0
  54. code_puppy/tui/screens/tools.py +74 -0
  55. code_puppy/tui/tests/__init__.py +1 -0
  56. code_puppy/tui/tests/test_chat_message.py +28 -0
  57. code_puppy/tui/tests/test_chat_view.py +88 -0
  58. code_puppy/tui/tests/test_command_history.py +89 -0
  59. code_puppy/tui/tests/test_copy_button.py +191 -0
  60. code_puppy/tui/tests/test_custom_widgets.py +27 -0
  61. code_puppy/tui/tests/test_disclaimer.py +27 -0
  62. code_puppy/tui/tests/test_enums.py +15 -0
  63. code_puppy/tui/tests/test_file_browser.py +60 -0
  64. code_puppy/tui/tests/test_help.py +38 -0
  65. code_puppy/tui/tests/test_history_file_reader.py +107 -0
  66. code_puppy/tui/tests/test_input_area.py +33 -0
  67. code_puppy/tui/tests/test_settings.py +44 -0
  68. code_puppy/tui/tests/test_sidebar.py +33 -0
  69. code_puppy/tui/tests/test_sidebar_history.py +153 -0
  70. code_puppy/tui/tests/test_sidebar_history_navigation.py +132 -0
  71. code_puppy/tui/tests/test_status_bar.py +54 -0
  72. code_puppy/tui/tests/test_timestamped_history.py +52 -0
  73. code_puppy/tui/tests/test_tools.py +82 -0
  74. code_puppy/version_checker.py +26 -3
  75. {code_puppy-0.0.97.dist-info → code_puppy-0.0.118.dist-info}/METADATA +9 -2
  76. code_puppy-0.0.118.dist-info/RECORD +86 -0
  77. code_puppy-0.0.97.dist-info/RECORD +0 -32
  78. {code_puppy-0.0.97.data → code_puppy-0.0.118.data}/data/code_puppy/models.json +0 -0
  79. {code_puppy-0.0.97.dist-info → code_puppy-0.0.118.dist-info}/WHEEL +0 -0
  80. {code_puppy-0.0.97.dist-info → code_puppy-0.0.118.dist-info}/entry_points.txt +0 -0
  81. {code_puppy-0.0.97.dist-info → code_puppy-0.0.118.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