code-puppy 0.0.96__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.96.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.96.dist-info/RECORD +0 -32
  78. {code_puppy-0.0.96.data → code_puppy-0.0.118.data}/data/code_puppy/models.json +0 -0
  79. {code_puppy-0.0.96.dist-info → code_puppy-0.0.118.dist-info}/WHEEL +0 -0
  80. {code_puppy-0.0.96.dist-info → code_puppy-0.0.118.dist-info}/entry_points.txt +0 -0
  81. {code_puppy-0.0.96.dist-info → code_puppy-0.0.118.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,218 @@
1
+ """
2
+ Modal component for displaying command history entries.
3
+ """
4
+
5
+ from textual import on
6
+ from textual.app import ComposeResult
7
+ from textual.containers import Container, Horizontal
8
+ from textual.events import Key
9
+ from textual.screen import ModalScreen
10
+ from textual.widgets import Button, Label, Static
11
+
12
+ from ..messages import CommandSelected
13
+
14
+
15
+ class CommandHistoryModal(ModalScreen):
16
+ """Modal for displaying a command history entry."""
17
+
18
+ def __init__(self, **kwargs):
19
+ """Initialize the modal with command history data.
20
+
21
+ Args:
22
+ **kwargs: Additional arguments to pass to the parent class
23
+ """
24
+ super().__init__(**kwargs)
25
+
26
+ # Get the current command from the sidebar
27
+ try:
28
+ # We'll get everything from the sidebar on demand
29
+ self.sidebar = None
30
+ self.command = ""
31
+ self.timestamp = ""
32
+ except Exception:
33
+ self.command = ""
34
+ self.timestamp = ""
35
+
36
+ # UI components to update
37
+ self.command_display = None
38
+ self.timestamp_display = None
39
+
40
+ def on_mount(self) -> None:
41
+ """Setup when the modal is mounted."""
42
+ # Get the sidebar and current command entry
43
+ try:
44
+ self.sidebar = self.app.query_one("Sidebar")
45
+ current_entry = self.sidebar.get_current_command_entry()
46
+ self.command = current_entry["command"]
47
+ self.timestamp = current_entry["timestamp"]
48
+ self.update_display()
49
+ except Exception as e:
50
+ import logging
51
+
52
+ logging.debug(f"Error initializing modal: {str(e)}")
53
+
54
+ DEFAULT_CSS = """
55
+ CommandHistoryModal {
56
+ align: center middle;
57
+ }
58
+
59
+ #modal-container {
60
+ width: 80%;
61
+ max-width: 100;
62
+ /* Set a definite height that's large enough but fits on screen */
63
+ height: 22; /* Increased height to make room for navigation hint */
64
+ min-height: 18;
65
+ background: $surface;
66
+ border: solid $primary;
67
+ /* Increase vertical padding to add more space between elements */
68
+ padding: 1 2;
69
+ /* Use vertical layout to ensure proper element sizing */
70
+ layout: vertical;
71
+ }
72
+
73
+ #timestamp-display {
74
+ width: 100%;
75
+ margin-bottom: 1;
76
+ color: $text-muted;
77
+ text-align: right;
78
+ /* Fix the height */
79
+ height: 1;
80
+ margin-top: 0;
81
+ }
82
+
83
+ #command-display {
84
+ width: 100%;
85
+ /* Allow this container to grow/shrink as needed but keep buttons visible */
86
+ min-height: 3;
87
+ height: 1fr;
88
+ max-height: 12;
89
+ padding: 0 1;
90
+ margin-bottom: 1;
91
+ margin-top: 1;
92
+ background: $surface-darken-1;
93
+ border: solid $primary-darken-2;
94
+ overflow: auto;
95
+ }
96
+
97
+ #nav-hint {
98
+ width: 100%;
99
+ color: $text;
100
+ text-align: center;
101
+ margin: 1 0;
102
+ }
103
+
104
+ .button-container {
105
+ width: 100%;
106
+ /* Fix the height to ensure buttons are always visible */
107
+ height: 3;
108
+ align-horizontal: right;
109
+ margin-top: 1;
110
+ }
111
+
112
+ Button {
113
+ margin-right: 1;
114
+ }
115
+
116
+ #use-button {
117
+ background: $success;
118
+ }
119
+
120
+ #cancel-button {
121
+ background: $primary-darken-1;
122
+ }
123
+ """
124
+
125
+ def compose(self) -> ComposeResult:
126
+ """Create the modal layout."""
127
+ with Container(id="modal-container"):
128
+ # Header with timestamp
129
+ self.timestamp_display = Label(
130
+ f"Timestamp: {self.timestamp}", id="timestamp-display"
131
+ )
132
+ yield self.timestamp_display
133
+
134
+ # Scrollable content area that can expand/contract as needed
135
+ # The content will scroll if it's too long, ensuring buttons remain visible
136
+ with Container(id="command-display"):
137
+ self.command_display = Static(self.command)
138
+ yield self.command_display
139
+
140
+ # Super simple navigation hint
141
+ yield Label("Press Up/Down arrows to navigate history", id="nav-hint")
142
+
143
+ # Fixed button container at the bottom
144
+ with Horizontal(classes="button-container"):
145
+ yield Button("Cancel", id="cancel-button", variant="default")
146
+ yield Button("Use Command", id="use-button", variant="primary")
147
+
148
+ def on_key(self, event: Key) -> None:
149
+ """Handle key events for navigation."""
150
+ # Handle arrow keys for navigation
151
+ if event.key == "down":
152
+ self.navigate_to_next_command()
153
+ event.prevent_default()
154
+ elif event.key == "up":
155
+ self.navigate_to_previous_command()
156
+ event.prevent_default()
157
+ elif event.key == "escape":
158
+ self.app.pop_screen()
159
+ event.prevent_default()
160
+
161
+ def navigate_to_next_command(self) -> None:
162
+ """Navigate to the next command in history."""
163
+ try:
164
+ # Get the sidebar
165
+ if not self.sidebar:
166
+ self.sidebar = self.app.query_one("Sidebar")
167
+
168
+ # Use sidebar's method to navigate
169
+ if self.sidebar.navigate_to_next_command():
170
+ # Get updated command entry
171
+ current_entry = self.sidebar.get_current_command_entry()
172
+ self.command = current_entry["command"]
173
+ self.timestamp = current_entry["timestamp"]
174
+ self.update_display()
175
+ except Exception as e:
176
+ # Log the error but don't crash
177
+ import logging
178
+
179
+ logging.debug(f"Error navigating to next command: {str(e)}")
180
+
181
+ def navigate_to_previous_command(self) -> None:
182
+ """Navigate to the previous command in history."""
183
+ try:
184
+ # Get the sidebar
185
+ if not self.sidebar:
186
+ self.sidebar = self.app.query_one("Sidebar")
187
+
188
+ # Use sidebar's method to navigate
189
+ if self.sidebar.navigate_to_previous_command():
190
+ # Get updated command entry
191
+ current_entry = self.sidebar.get_current_command_entry()
192
+ self.command = current_entry["command"]
193
+ self.timestamp = current_entry["timestamp"]
194
+ self.update_display()
195
+ except Exception as e:
196
+ # Log the error but don't crash
197
+ import logging
198
+
199
+ logging.debug(f"Error navigating to previous command: {str(e)}")
200
+
201
+ def update_display(self) -> None:
202
+ """Update the display with the current command and timestamp."""
203
+ if self.command_display:
204
+ self.command_display.update(self.command)
205
+ if self.timestamp_display:
206
+ self.timestamp_display.update(f"Timestamp: {self.timestamp}")
207
+
208
+ @on(Button.Pressed, "#use-button")
209
+ def use_command(self) -> None:
210
+ """Handle use button press."""
211
+ # Post a message to the app with the selected command
212
+ self.post_message(CommandSelected(self.command))
213
+ self.app.pop_screen()
214
+
215
+ @on(Button.Pressed, "#cancel-button")
216
+ def cancel(self) -> None:
217
+ """Handle cancel button press."""
218
+ self.app.pop_screen()
@@ -0,0 +1,139 @@
1
+ """
2
+ Copy button component for copying agent responses to clipboard.
3
+ """
4
+
5
+ import subprocess
6
+ import sys
7
+ from typing import Optional
8
+
9
+ from textual.binding import Binding
10
+ from textual.events import Click
11
+ from textual.message import Message
12
+ from textual.widgets import Button
13
+
14
+
15
+ class CopyButton(Button):
16
+ """A button that copies associated text to the clipboard."""
17
+
18
+ DEFAULT_CSS = """
19
+ CopyButton {
20
+ width: auto;
21
+ height: 3;
22
+ min-width: 8;
23
+ margin: 0 1 1 1;
24
+ padding: 0 1;
25
+ background: $primary;
26
+ color: $text;
27
+ border: none;
28
+ text-align: center;
29
+ }
30
+
31
+ CopyButton:hover {
32
+ background: $accent;
33
+ color: $text;
34
+ }
35
+
36
+ CopyButton:focus {
37
+ background: $accent;
38
+ color: $text;
39
+ text-style: bold;
40
+ }
41
+
42
+ CopyButton.-pressed {
43
+ background: $success;
44
+ color: $text;
45
+ }
46
+ """
47
+
48
+ BINDINGS = [
49
+ Binding("enter", "press", "Copy", show=False),
50
+ Binding("space", "press", "Copy", show=False),
51
+ ]
52
+
53
+ def __init__(self, text_to_copy: str, **kwargs):
54
+ super().__init__("📋 Copy", **kwargs)
55
+ self.text_to_copy = text_to_copy
56
+ self._original_label = "📋 Copy"
57
+ self._copied_label = "✅ Copied!"
58
+
59
+ class CopyCompleted(Message):
60
+ """Message sent when text is successfully copied."""
61
+
62
+ def __init__(self, success: bool, error: Optional[str] = None):
63
+ super().__init__()
64
+ self.success = success
65
+ self.error = error
66
+
67
+ def copy_to_clipboard(self, text: str) -> tuple[bool, Optional[str]]:
68
+ """
69
+ Copy text to clipboard using platform-appropriate method.
70
+
71
+ Returns:
72
+ tuple: (success: bool, error_message: Optional[str])
73
+ """
74
+ try:
75
+ if sys.platform == "darwin": # macOS
76
+ subprocess.run(
77
+ ["pbcopy"], input=text, text=True, check=True, capture_output=True
78
+ )
79
+ elif sys.platform == "win32": # Windows
80
+ subprocess.run(
81
+ ["clip"], input=text, text=True, check=True, capture_output=True
82
+ )
83
+ else: # Linux and other Unix-like systems
84
+ # Try xclip first, then xsel as fallback
85
+ try:
86
+ subprocess.run(
87
+ ["xclip", "-selection", "clipboard"],
88
+ input=text,
89
+ text=True,
90
+ check=True,
91
+ capture_output=True,
92
+ )
93
+ except (subprocess.CalledProcessError, FileNotFoundError):
94
+ # Fallback to xsel
95
+ subprocess.run(
96
+ ["xsel", "--clipboard", "--input"],
97
+ input=text,
98
+ text=True,
99
+ check=True,
100
+ capture_output=True,
101
+ )
102
+
103
+ return True, None
104
+
105
+ except subprocess.CalledProcessError as e:
106
+ return False, f"Clipboard command failed: {e}"
107
+ except FileNotFoundError:
108
+ if sys.platform not in ["darwin", "win32"]:
109
+ return (
110
+ False,
111
+ "Clipboard utilities not found. Please install xclip or xsel.",
112
+ )
113
+ else:
114
+ return False, "System clipboard command not found."
115
+ except Exception as e:
116
+ return False, f"Unexpected error: {e}"
117
+
118
+ def on_click(self, event: Click) -> None:
119
+ """Handle button click to copy text."""
120
+ self.action_press()
121
+
122
+ def action_press(self) -> None:
123
+ """Copy the text to clipboard and provide visual feedback."""
124
+ success, error = self.copy_to_clipboard(self.text_to_copy)
125
+
126
+ if success:
127
+ # Visual feedback - change button text temporarily
128
+ self.label = self._copied_label
129
+ self.add_class("-pressed")
130
+
131
+ # Reset button appearance after a short delay
132
+ # self.set_timer(1.5, self._reset_button_appearance)
133
+
134
+ # Send message about copy operation
135
+ self.post_message(self.CopyCompleted(success, error))
136
+
137
+ def update_text_to_copy(self, new_text: str) -> None:
138
+ """Update the text that will be copied when button is pressed."""
139
+ self.text_to_copy = new_text
@@ -0,0 +1,58 @@
1
+ """
2
+ Custom widget components for the TUI.
3
+ """
4
+
5
+ from textual.binding import Binding
6
+ from textual.events import Key
7
+ from textual.message import Message
8
+ from textual.widgets import TextArea
9
+
10
+
11
+ class CustomTextArea(TextArea):
12
+ """Custom TextArea that sends a message with Enter and allows new lines with Shift+Enter."""
13
+
14
+ # Define key bindings
15
+ BINDINGS = [
16
+ Binding("alt+enter", "insert_newline", ""),
17
+ ]
18
+
19
+ def __init__(self, *args, **kwargs):
20
+ super().__init__(*args, **kwargs)
21
+
22
+ def on_key(self, event):
23
+ """Handle key events before they reach the internal _on_key handler."""
24
+ # Explicitly handle escape+enter/alt+enter sequences
25
+ if event.key == "escape+enter" or event.key == "alt+enter":
26
+ self.action_insert_newline()
27
+ event.prevent_default()
28
+ event.stop()
29
+ return
30
+
31
+ def _on_key(self, event: Key) -> None:
32
+ """Override internal key handler to intercept Enter keys."""
33
+ # Handle Enter key specifically
34
+ if event.key == "enter":
35
+ # Check if this key is part of an escape sequence (Alt+Enter)
36
+ if hasattr(event, "is_cursor_sequence") or (
37
+ hasattr(event, "meta") and event.meta
38
+ ):
39
+ # If it's part of an escape sequence, let the parent handle it
40
+ # so that bindings can process it
41
+ super()._on_key(event)
42
+ return
43
+
44
+ # This handles plain Enter only, not escape+enter
45
+ self.post_message(self.MessageSent())
46
+ return # Don't call super() to prevent default newline behavior
47
+
48
+ # Let TextArea handle other keys
49
+ super()._on_key(event)
50
+
51
+ def action_insert_newline(self) -> None:
52
+ """Action to insert a new line - called by shift+enter and escape+enter bindings."""
53
+ self.insert("\n")
54
+
55
+ class MessageSent(Message):
56
+ """Message sent when Enter key is pressed (without Shift)."""
57
+
58
+ pass
@@ -0,0 +1,167 @@
1
+ """
2
+ Input area component for message input.
3
+ """
4
+
5
+ from textual.app import ComposeResult
6
+ from textual.containers import Container, Horizontal
7
+ from textual.message import Message
8
+ from textual.reactive import reactive
9
+ from textual.widgets import Button, Static
10
+
11
+ from code_puppy.messaging.spinner import TextualSpinner
12
+
13
+ from .custom_widgets import CustomTextArea
14
+
15
+ # Alias SimpleSpinnerWidget to TextualSpinner for backward compatibility
16
+ SimpleSpinnerWidget = TextualSpinner
17
+
18
+
19
+ class SubmitCancelButton(Button):
20
+ """A button that toggles between submit and cancel states."""
21
+
22
+ is_cancel_mode = reactive(False)
23
+
24
+ DEFAULT_CSS = """
25
+ SubmitCancelButton {
26
+ width: 3;
27
+ min-width: 3;
28
+ height: 3;
29
+ content-align: center middle;
30
+ border: none;
31
+ background: $surface;
32
+ }
33
+
34
+ SubmitCancelButton:focus {
35
+ border: none;
36
+ color: $surface;
37
+ background: $surface;
38
+ }
39
+
40
+ SubmitCancelButton:hover {
41
+ border: none;
42
+ background: $surface;
43
+ }
44
+ """
45
+
46
+ def __init__(self, **kwargs):
47
+ super().__init__("▶️", **kwargs)
48
+ self.id = "submit-cancel-button"
49
+
50
+ def watch_is_cancel_mode(self, is_cancel: bool) -> None:
51
+ """Update the button label when cancel mode changes."""
52
+ self.label = "⏹️" if is_cancel else "▶️"
53
+
54
+ def on_click(self) -> None:
55
+ """Handle click event and bubble it up to parent."""
56
+ # When clicked, send a ButtonClicked message that will be handled by the parent
57
+ self.post_message(self.Clicked(self))
58
+
59
+ class Clicked(Message):
60
+ """Button was clicked."""
61
+
62
+ def __init__(self, button: "SubmitCancelButton") -> None:
63
+ self.is_cancel_mode = button.is_cancel_mode
64
+ super().__init__()
65
+
66
+
67
+ class InputArea(Container):
68
+ """Input area with text input, spinner, help text, and send button."""
69
+
70
+ DEFAULT_CSS = """
71
+ InputArea {
72
+ dock: bottom;
73
+ height: 9;
74
+ margin: 1;
75
+ }
76
+
77
+ #spinner {
78
+ height: 1;
79
+ width: 1fr;
80
+ margin: 0 3 0 1;
81
+ content-align: left middle;
82
+ text-align: left;
83
+ display: none;
84
+ }
85
+
86
+ #spinner.visible {
87
+ display: block;
88
+ }
89
+
90
+ #input-container {
91
+ height: 5;
92
+ width: 1fr;
93
+ margin: 1 3 0 1;
94
+ align: center middle;
95
+ }
96
+
97
+ #input-field {
98
+ height: 5;
99
+ width: 1fr;
100
+ border: round $primary;
101
+ background: $surface;
102
+ }
103
+
104
+ #submit-cancel-button {
105
+ height: 3;
106
+ width: 3;
107
+ min-width: 3;
108
+ margin: 1 0 1 1;
109
+ content-align: center middle;
110
+ border: none;
111
+ background: $surface;
112
+ }
113
+
114
+ #input-help {
115
+ height: 1;
116
+ width: 1fr;
117
+ margin: 0 3 1 1;
118
+ color: $text-muted;
119
+ text-align: center;
120
+ }
121
+ """
122
+
123
+ def on_mount(self) -> None:
124
+ """Initialize the button state based on the app's agent_busy state."""
125
+ app = self.app
126
+ if hasattr(app, "agent_busy"):
127
+ button = self.query_one(SubmitCancelButton)
128
+ button.is_cancel_mode = app.agent_busy
129
+
130
+ def compose(self) -> ComposeResult:
131
+ yield SimpleSpinnerWidget(id="spinner")
132
+ with Horizontal(id="input-container"):
133
+ yield CustomTextArea(id="input-field", show_line_numbers=False)
134
+ yield SubmitCancelButton()
135
+ yield Static(
136
+ "Enter to send • Alt+Enter for new line • Ctrl+1 for help",
137
+ id="input-help",
138
+ )
139
+
140
+ def on_submit_cancel_button_clicked(
141
+ self, event: SubmitCancelButton.Clicked
142
+ ) -> None:
143
+ """Handle button clicks based on current mode."""
144
+ if event.is_cancel_mode:
145
+ # Cancel mode - stop the current process
146
+ self.post_message(self.CancelRequested())
147
+ else:
148
+ # Submit mode - send the message
149
+ self.post_message(self.SubmitRequested())
150
+
151
+ # Return focus to the input field
152
+ self.app.call_after_refresh(self.focus_input_field)
153
+
154
+ def focus_input_field(self) -> None:
155
+ """Focus the input field after button click."""
156
+ input_field = self.query_one("#input-field")
157
+ input_field.focus()
158
+
159
+ class SubmitRequested(Message):
160
+ """Request to submit the current input."""
161
+
162
+ pass
163
+
164
+ class CancelRequested(Message):
165
+ """Request to cancel the current process."""
166
+
167
+ pass