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.
- 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 +256 -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.96.dist-info → code_puppy-0.0.118.dist-info}/METADATA +9 -2
- code_puppy-0.0.118.dist-info/RECORD +86 -0
- code_puppy-0.0.96.dist-info/RECORD +0 -32
- {code_puppy-0.0.96.data → code_puppy-0.0.118.data}/data/code_puppy/models.json +0 -0
- {code_puppy-0.0.96.dist-info → code_puppy-0.0.118.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.96.dist-info → code_puppy-0.0.118.dist-info}/entry_points.txt +0 -0
- {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
|