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.
- 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.97.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.97.dist-info/RECORD +0 -32
- {code_puppy-0.0.97.data → code_puppy-0.0.118.data}/data/code_puppy/models.json +0 -0
- {code_puppy-0.0.97.dist-info → code_puppy-0.0.118.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.97.dist-info → code_puppy-0.0.118.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.97.dist-info → code_puppy-0.0.118.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Help modal screen.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from textual import on
|
|
6
|
+
from textual.app import ComposeResult
|
|
7
|
+
from textual.containers import Container, VerticalScroll
|
|
8
|
+
from textual.screen import ModalScreen
|
|
9
|
+
from textual.widgets import Button, Static
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HelpScreen(ModalScreen):
|
|
13
|
+
"""Help modal screen."""
|
|
14
|
+
|
|
15
|
+
DEFAULT_CSS = """
|
|
16
|
+
HelpScreen {
|
|
17
|
+
align: center middle;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
#help-dialog {
|
|
21
|
+
width: 80;
|
|
22
|
+
height: 30;
|
|
23
|
+
border: thick $primary;
|
|
24
|
+
background: $surface;
|
|
25
|
+
padding: 1;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
#help-content {
|
|
29
|
+
height: 1fr;
|
|
30
|
+
margin: 0 0 1 0;
|
|
31
|
+
overflow-y: auto;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
#help-buttons {
|
|
35
|
+
layout: horizontal;
|
|
36
|
+
height: 3;
|
|
37
|
+
align: center middle;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#dismiss-button {
|
|
41
|
+
margin: 0 1;
|
|
42
|
+
}
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def compose(self) -> ComposeResult:
|
|
46
|
+
with Container(id="help-dialog"):
|
|
47
|
+
yield Static("📚 Code Puppy TUI Help", id="help-title")
|
|
48
|
+
with VerticalScroll(id="help-content"):
|
|
49
|
+
yield Static(self.get_help_content(), id="help-text")
|
|
50
|
+
with Container(id="help-buttons"):
|
|
51
|
+
yield Button("Dismiss", id="dismiss-button", variant="primary")
|
|
52
|
+
|
|
53
|
+
def get_help_content(self) -> str:
|
|
54
|
+
"""Get the help content text."""
|
|
55
|
+
try:
|
|
56
|
+
# Get terminal width for responsive help
|
|
57
|
+
terminal_width = self.app.size.width if hasattr(self.app, "size") else 80
|
|
58
|
+
except Exception:
|
|
59
|
+
terminal_width = 80
|
|
60
|
+
|
|
61
|
+
if terminal_width < 60:
|
|
62
|
+
# Compact help for narrow terminals
|
|
63
|
+
return """
|
|
64
|
+
Code Puppy TUI (Compact Mode):
|
|
65
|
+
|
|
66
|
+
Controls:
|
|
67
|
+
- Enter: Send message
|
|
68
|
+
- Ctrl+Enter: New line
|
|
69
|
+
- Ctrl+Q: Quit
|
|
70
|
+
- Ctrl+2: Toggle History
|
|
71
|
+
- Ctrl+3: Settings
|
|
72
|
+
- Ctrl+4: Tools
|
|
73
|
+
- Ctrl+5: Focus prompt
|
|
74
|
+
- Ctrl+6: Focus response
|
|
75
|
+
|
|
76
|
+
Use this help for full details.
|
|
77
|
+
"""
|
|
78
|
+
else:
|
|
79
|
+
# Full help text
|
|
80
|
+
return """
|
|
81
|
+
Code Puppy TUI Help:
|
|
82
|
+
|
|
83
|
+
Input Controls:
|
|
84
|
+
- Enter: Send message
|
|
85
|
+
- ALT+Enter: New line (multi-line input)
|
|
86
|
+
- Standard text editing shortcuts supported
|
|
87
|
+
|
|
88
|
+
Keyboard Shortcuts:
|
|
89
|
+
- Ctrl+Q/Ctrl+C: Quit application
|
|
90
|
+
- Ctrl+L: Clear chat history
|
|
91
|
+
- Ctrl+1: Show this help
|
|
92
|
+
- Ctrl+2: Toggle History
|
|
93
|
+
- Ctrl+3: Open settings
|
|
94
|
+
- Ctrl+4: Tools
|
|
95
|
+
- Ctrl+5: Focus prompt (input field)
|
|
96
|
+
- Ctrl+6: Focus response (chat area)
|
|
97
|
+
|
|
98
|
+
Chat Navigation:
|
|
99
|
+
- Ctrl+Up/Down: Scroll chat up/down
|
|
100
|
+
- Ctrl+Home: Scroll to top
|
|
101
|
+
- Ctrl+End: Scroll to bottom
|
|
102
|
+
|
|
103
|
+
Commands:
|
|
104
|
+
- /clear: Clear chat history
|
|
105
|
+
- /m <model>: Switch model
|
|
106
|
+
- /cd <dir>: Change directory
|
|
107
|
+
- /help: Show help
|
|
108
|
+
- /status: Show current status
|
|
109
|
+
|
|
110
|
+
Use the input area at the bottom to type messages.
|
|
111
|
+
Press Ctrl+2 to view History when needed.
|
|
112
|
+
Agent responses support syntax highlighting for code blocks.
|
|
113
|
+
Press Ctrl+3 to access all configuration settings.
|
|
114
|
+
|
|
115
|
+
Copy Feature:
|
|
116
|
+
- 📋 Copy buttons appear after agent responses
|
|
117
|
+
- Click or press Enter/Space on copy button to copy content
|
|
118
|
+
- Raw markdown content is copied to clipboard
|
|
119
|
+
- Visual feedback shows copy success/failure
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
@on(Button.Pressed, "#dismiss-button")
|
|
123
|
+
def dismiss_help(self) -> None:
|
|
124
|
+
"""Dismiss the help modal."""
|
|
125
|
+
self.dismiss()
|
|
126
|
+
|
|
127
|
+
def on_key(self, event) -> None:
|
|
128
|
+
"""Handle key events."""
|
|
129
|
+
if event.key == "escape":
|
|
130
|
+
self.dismiss()
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Settings modal screen.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from textual import on
|
|
6
|
+
from textual.app import ComposeResult
|
|
7
|
+
from textual.containers import Container
|
|
8
|
+
from textual.screen import ModalScreen
|
|
9
|
+
from textual.widgets import Button, Input, Select, Static
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SettingsScreen(ModalScreen):
|
|
13
|
+
"""Settings configuration screen."""
|
|
14
|
+
|
|
15
|
+
DEFAULT_CSS = """
|
|
16
|
+
SettingsScreen {
|
|
17
|
+
align: center middle;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
#settings-dialog {
|
|
21
|
+
width: 80;
|
|
22
|
+
height: 33;
|
|
23
|
+
border: thick $primary;
|
|
24
|
+
background: $surface;
|
|
25
|
+
padding: 1;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
#settings-form {
|
|
29
|
+
height: 1fr;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.setting-row {
|
|
33
|
+
layout: horizontal;
|
|
34
|
+
height: 3;
|
|
35
|
+
margin: 0 0 1 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.setting-label {
|
|
39
|
+
width: 20;
|
|
40
|
+
text-align: right;
|
|
41
|
+
padding: 1 1 0 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.setting-input {
|
|
45
|
+
width: 1fr;
|
|
46
|
+
margin: 0 0 0 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* Additional styling for static input values */
|
|
50
|
+
#yolo-static {
|
|
51
|
+
padding: 1 0 0 0; /* Align text vertically with other inputs */
|
|
52
|
+
color: $success; /* Use success color to emphasize it's enabled */
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#settings-buttons {
|
|
56
|
+
layout: horizontal;
|
|
57
|
+
height: 3;
|
|
58
|
+
align: center middle;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#save-button, #cancel-button {
|
|
62
|
+
margin: 0 1;
|
|
63
|
+
}
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, **kwargs):
|
|
67
|
+
super().__init__(**kwargs)
|
|
68
|
+
self.settings_data = {}
|
|
69
|
+
|
|
70
|
+
def compose(self) -> ComposeResult:
|
|
71
|
+
with Container(id="settings-dialog"):
|
|
72
|
+
yield Static("⚙️ Settings Configuration", id="settings-title")
|
|
73
|
+
with Container(id="settings-form"):
|
|
74
|
+
with Container(classes="setting-row"):
|
|
75
|
+
yield Static("Puppy Name:", classes="setting-label")
|
|
76
|
+
yield Input(id="puppy-name-input", classes="setting-input")
|
|
77
|
+
|
|
78
|
+
with Container(classes="setting-row"):
|
|
79
|
+
yield Static("Owner Name:", classes="setting-label")
|
|
80
|
+
yield Input(id="owner-name-input", classes="setting-input")
|
|
81
|
+
|
|
82
|
+
with Container(classes="setting-row"):
|
|
83
|
+
yield Static("Model:", classes="setting-label")
|
|
84
|
+
yield Select([], id="model-select", classes="setting-input")
|
|
85
|
+
|
|
86
|
+
with Container(classes="setting-row"):
|
|
87
|
+
yield Static("YOLO Mode:", classes="setting-label")
|
|
88
|
+
yield Static(
|
|
89
|
+
"✅ Enabled (always on in TUI)",
|
|
90
|
+
id="yolo-static",
|
|
91
|
+
classes="setting-input",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
with Container(classes="setting-row"):
|
|
95
|
+
yield Static("Protected Tokens:", classes="setting-label")
|
|
96
|
+
yield Input(
|
|
97
|
+
id="protected-tokens-input",
|
|
98
|
+
classes="setting-input",
|
|
99
|
+
placeholder="e.g., 50000",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
with Container(classes="setting-row"):
|
|
103
|
+
yield Static("Summary Threshold:", classes="setting-label")
|
|
104
|
+
yield Input(
|
|
105
|
+
id="summary-threshold-input",
|
|
106
|
+
classes="setting-input",
|
|
107
|
+
placeholder="e.g., 0.85",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
with Container(id="settings-buttons"):
|
|
111
|
+
yield Button("Save", id="save-button", variant="primary")
|
|
112
|
+
yield Button("Cancel", id="cancel-button")
|
|
113
|
+
|
|
114
|
+
def on_mount(self) -> None:
|
|
115
|
+
"""Load current settings when the screen mounts."""
|
|
116
|
+
from code_puppy.config import (
|
|
117
|
+
get_model_name,
|
|
118
|
+
get_owner_name,
|
|
119
|
+
get_protected_token_count,
|
|
120
|
+
get_puppy_name,
|
|
121
|
+
get_summarization_threshold,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Load current values
|
|
125
|
+
puppy_name_input = self.query_one("#puppy-name-input", Input)
|
|
126
|
+
owner_name_input = self.query_one("#owner-name-input", Input)
|
|
127
|
+
model_select = self.query_one("#model-select", Select)
|
|
128
|
+
protected_tokens_input = self.query_one("#protected-tokens-input", Input)
|
|
129
|
+
summary_threshold_input = self.query_one("#summary-threshold-input", Input)
|
|
130
|
+
|
|
131
|
+
puppy_name_input.value = get_puppy_name() or ""
|
|
132
|
+
owner_name_input.value = get_owner_name() or ""
|
|
133
|
+
protected_tokens_input.value = str(get_protected_token_count())
|
|
134
|
+
summary_threshold_input.value = str(get_summarization_threshold())
|
|
135
|
+
|
|
136
|
+
# Load available models
|
|
137
|
+
self.load_model_options(model_select)
|
|
138
|
+
|
|
139
|
+
# Set current model selection
|
|
140
|
+
current_model = get_model_name()
|
|
141
|
+
model_select.value = current_model
|
|
142
|
+
|
|
143
|
+
# YOLO mode is always enabled in TUI mode
|
|
144
|
+
|
|
145
|
+
def load_model_options(self, model_select):
|
|
146
|
+
"""Load available models into the model select widget."""
|
|
147
|
+
try:
|
|
148
|
+
# Use the same method that interactive mode uses to load models
|
|
149
|
+
import os
|
|
150
|
+
|
|
151
|
+
from code_puppy.config import CONFIG_DIR
|
|
152
|
+
from code_puppy.model_factory import ModelFactory
|
|
153
|
+
|
|
154
|
+
# Load models using the same path and method as interactive mode
|
|
155
|
+
models_config_path = os.path.join(CONFIG_DIR, "models.json")
|
|
156
|
+
models_data = ModelFactory.load_config(models_config_path)
|
|
157
|
+
|
|
158
|
+
# Create options as (display_name, model_name) tuples
|
|
159
|
+
model_options = []
|
|
160
|
+
for model_name, model_config in models_data.items():
|
|
161
|
+
model_type = model_config.get("type", "unknown")
|
|
162
|
+
display_name = f"{model_name} ({model_type})"
|
|
163
|
+
model_options.append((display_name, model_name))
|
|
164
|
+
|
|
165
|
+
# Set the options on the select widget
|
|
166
|
+
model_select.set_options(model_options)
|
|
167
|
+
|
|
168
|
+
except Exception:
|
|
169
|
+
# Fallback to a basic option if loading fails
|
|
170
|
+
model_select.set_options([("gpt-4.1 (openai)", "gpt-4.1")])
|
|
171
|
+
|
|
172
|
+
@on(Button.Pressed, "#save-button")
|
|
173
|
+
def save_settings(self) -> None:
|
|
174
|
+
"""Save the modified settings."""
|
|
175
|
+
from code_puppy.config import set_config_value, set_model_name
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
# Get values from inputs
|
|
179
|
+
puppy_name = self.query_one("#puppy-name-input", Input).value.strip()
|
|
180
|
+
owner_name = self.query_one("#owner-name-input", Input).value.strip()
|
|
181
|
+
selected_model = self.query_one("#model-select", Select).value
|
|
182
|
+
yolo_mode = "true" # Always set to true in TUI mode
|
|
183
|
+
protected_tokens = self.query_one(
|
|
184
|
+
"#protected-tokens-input", Input
|
|
185
|
+
).value.strip()
|
|
186
|
+
summary_threshold = self.query_one(
|
|
187
|
+
"#summary-threshold-input", Input
|
|
188
|
+
).value.strip()
|
|
189
|
+
|
|
190
|
+
# Validate and save
|
|
191
|
+
if puppy_name:
|
|
192
|
+
set_config_value("puppy_name", puppy_name)
|
|
193
|
+
if owner_name:
|
|
194
|
+
set_config_value("owner_name", owner_name)
|
|
195
|
+
|
|
196
|
+
# Save model selection
|
|
197
|
+
if selected_model:
|
|
198
|
+
set_model_name(selected_model)
|
|
199
|
+
|
|
200
|
+
set_config_value("yolo_mode", yolo_mode)
|
|
201
|
+
|
|
202
|
+
# Validate and save protected tokens
|
|
203
|
+
if protected_tokens.isdigit():
|
|
204
|
+
tokens_value = int(protected_tokens)
|
|
205
|
+
if tokens_value >= 1000: # Minimum validation
|
|
206
|
+
set_config_value("protected_token_count", protected_tokens)
|
|
207
|
+
else:
|
|
208
|
+
raise ValueError("Protected tokens must be at least 1000")
|
|
209
|
+
elif protected_tokens: # If not empty but not digit
|
|
210
|
+
raise ValueError("Protected tokens must be a valid number")
|
|
211
|
+
|
|
212
|
+
# Validate and save summary threshold
|
|
213
|
+
if summary_threshold:
|
|
214
|
+
try:
|
|
215
|
+
threshold_value = float(summary_threshold)
|
|
216
|
+
if 0.1 <= threshold_value <= 0.95: # Same bounds as config function
|
|
217
|
+
set_config_value("summarization_threshold", summary_threshold)
|
|
218
|
+
else:
|
|
219
|
+
raise ValueError(
|
|
220
|
+
"Summary threshold must be between 0.1 and 0.95"
|
|
221
|
+
)
|
|
222
|
+
except ValueError as ve:
|
|
223
|
+
if "must be between" in str(ve):
|
|
224
|
+
raise ve
|
|
225
|
+
else:
|
|
226
|
+
raise ValueError(
|
|
227
|
+
"Summary threshold must be a valid decimal number"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# Return success message with model change info
|
|
231
|
+
message = "Settings saved successfully!"
|
|
232
|
+
if selected_model:
|
|
233
|
+
message += f" Model switched to: {selected_model}"
|
|
234
|
+
|
|
235
|
+
self.dismiss(
|
|
236
|
+
{
|
|
237
|
+
"success": True,
|
|
238
|
+
"message": message,
|
|
239
|
+
"model_changed": bool(selected_model),
|
|
240
|
+
}
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
except Exception as e:
|
|
244
|
+
self.dismiss(
|
|
245
|
+
{"success": False, "message": f"Error saving settings: {str(e)}"}
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
@on(Button.Pressed, "#cancel-button")
|
|
249
|
+
def cancel_settings(self) -> None:
|
|
250
|
+
"""Cancel settings changes."""
|
|
251
|
+
self.dismiss({"success": False, "message": "Settings cancelled"})
|
|
252
|
+
|
|
253
|
+
def on_key(self, event) -> None:
|
|
254
|
+
"""Handle key events."""
|
|
255
|
+
if event.key == "escape":
|
|
256
|
+
self.cancel_settings()
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tools modal screen.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from textual import on
|
|
6
|
+
from textual.app import ComposeResult
|
|
7
|
+
from textual.containers import Container, VerticalScroll
|
|
8
|
+
from textual.screen import ModalScreen
|
|
9
|
+
from textual.widgets import Button, Markdown, Static
|
|
10
|
+
|
|
11
|
+
from code_puppy.tools.tools_content import tools_content
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ToolsScreen(ModalScreen):
|
|
15
|
+
"""Tools modal screen"""
|
|
16
|
+
|
|
17
|
+
DEFAULT_CSS = """
|
|
18
|
+
ToolsScreen {
|
|
19
|
+
align: center middle;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
#tools-dialog {
|
|
23
|
+
width: 95;
|
|
24
|
+
height: 40;
|
|
25
|
+
border: thick $primary;
|
|
26
|
+
background: $surface;
|
|
27
|
+
padding: 1;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
#tools-content {
|
|
31
|
+
height: 1fr;
|
|
32
|
+
margin: 0 0 1 0;
|
|
33
|
+
overflow-y: auto;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#tools-buttons {
|
|
37
|
+
layout: horizontal;
|
|
38
|
+
height: 3;
|
|
39
|
+
align: center middle;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
#dismiss-button {
|
|
43
|
+
margin: 0 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
#tools-markdown {
|
|
47
|
+
margin: 0;
|
|
48
|
+
padding: 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* Style markdown elements for better readability */
|
|
52
|
+
Markdown {
|
|
53
|
+
margin: 0;
|
|
54
|
+
padding: 0;
|
|
55
|
+
}
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def compose(self) -> ComposeResult:
|
|
59
|
+
with Container(id="tools-dialog"):
|
|
60
|
+
yield Static("🛠️ Cooper's Toolkit\n", id="tools-title")
|
|
61
|
+
with VerticalScroll(id="tools-content"):
|
|
62
|
+
yield Markdown(tools_content, id="tools-markdown")
|
|
63
|
+
with Container(id="tools-buttons"):
|
|
64
|
+
yield Button("Dismiss", id="dismiss-button", variant="primary")
|
|
65
|
+
|
|
66
|
+
@on(Button.Pressed, "#dismiss-button")
|
|
67
|
+
def dismiss_tools(self) -> None:
|
|
68
|
+
"""Dismiss the tools modal."""
|
|
69
|
+
self.dismiss()
|
|
70
|
+
|
|
71
|
+
def on_key(self, event) -> None:
|
|
72
|
+
"""Handle key events."""
|
|
73
|
+
if event.key == "escape":
|
|
74
|
+
self.dismiss()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Test package for tui
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
from code_puppy.tui.models.chat_message import ChatMessage
|
|
5
|
+
from code_puppy.tui.models.enums import MessageType
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestChatMessage(unittest.TestCase):
|
|
9
|
+
def test_chat_message_defaults(self):
|
|
10
|
+
msg = ChatMessage(
|
|
11
|
+
id="1", type=MessageType.USER, content="hi", timestamp=datetime.now()
|
|
12
|
+
)
|
|
13
|
+
self.assertEqual(msg.metadata, {})
|
|
14
|
+
|
|
15
|
+
def test_chat_message_with_metadata(self):
|
|
16
|
+
meta = {"foo": "bar"}
|
|
17
|
+
msg = ChatMessage(
|
|
18
|
+
id="2",
|
|
19
|
+
type=MessageType.AGENT,
|
|
20
|
+
content="hello",
|
|
21
|
+
timestamp=datetime.now(),
|
|
22
|
+
metadata=meta,
|
|
23
|
+
)
|
|
24
|
+
self.assertEqual(msg.metadata, meta)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
if __name__ == "__main__":
|
|
28
|
+
unittest.main()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from unittest.mock import patch
|
|
4
|
+
|
|
5
|
+
from code_puppy.tui.components.chat_view import ChatView
|
|
6
|
+
from code_puppy.tui.models.chat_message import ChatMessage
|
|
7
|
+
from code_puppy.tui.models.enums import MessageType
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestChatView(unittest.TestCase):
|
|
11
|
+
def setUp(self):
|
|
12
|
+
self.chat_view = ChatView()
|
|
13
|
+
|
|
14
|
+
@patch.object(ChatView, "mount")
|
|
15
|
+
def test_add_message_user(self, mock_mount):
|
|
16
|
+
msg = ChatMessage(
|
|
17
|
+
id="test-user-1",
|
|
18
|
+
type=MessageType.USER,
|
|
19
|
+
content="Hello",
|
|
20
|
+
timestamp=datetime.now(),
|
|
21
|
+
)
|
|
22
|
+
self.chat_view.add_message(msg)
|
|
23
|
+
self.assertIn(msg, self.chat_view.messages)
|
|
24
|
+
mock_mount.assert_called_once()
|
|
25
|
+
|
|
26
|
+
@patch.object(ChatView, "mount")
|
|
27
|
+
def test_add_message_agent(self, mock_mount):
|
|
28
|
+
msg = ChatMessage(
|
|
29
|
+
id="test-agent-1",
|
|
30
|
+
type=MessageType.AGENT,
|
|
31
|
+
content="Hi there!",
|
|
32
|
+
timestamp=datetime.now(),
|
|
33
|
+
)
|
|
34
|
+
self.chat_view.add_message(msg)
|
|
35
|
+
self.assertIn(msg, self.chat_view.messages)
|
|
36
|
+
mock_mount.assert_called_once()
|
|
37
|
+
|
|
38
|
+
@patch.object(ChatView, "mount")
|
|
39
|
+
def test_add_message_system(self, mock_mount):
|
|
40
|
+
msg = ChatMessage(
|
|
41
|
+
id="test-system-1",
|
|
42
|
+
type=MessageType.SYSTEM,
|
|
43
|
+
content="System message",
|
|
44
|
+
timestamp=datetime.now(),
|
|
45
|
+
)
|
|
46
|
+
self.chat_view.add_message(msg)
|
|
47
|
+
self.assertIn(msg, self.chat_view.messages)
|
|
48
|
+
mock_mount.assert_called_once()
|
|
49
|
+
|
|
50
|
+
@patch.object(ChatView, "mount")
|
|
51
|
+
def test_add_message_error(self, mock_mount):
|
|
52
|
+
msg = ChatMessage(
|
|
53
|
+
id="test-error-1",
|
|
54
|
+
type=MessageType.ERROR,
|
|
55
|
+
content="Error occurred",
|
|
56
|
+
timestamp=datetime.now(),
|
|
57
|
+
)
|
|
58
|
+
self.chat_view.add_message(msg)
|
|
59
|
+
self.assertIn(msg, self.chat_view.messages)
|
|
60
|
+
mock_mount.assert_called_once()
|
|
61
|
+
|
|
62
|
+
@patch.object(ChatView, "mount")
|
|
63
|
+
@patch.object(ChatView, "query")
|
|
64
|
+
def test_clear_messages(self, mock_query, mock_mount):
|
|
65
|
+
# Mock the query method to return empty iterables
|
|
66
|
+
mock_query.return_value = []
|
|
67
|
+
|
|
68
|
+
msg = ChatMessage(
|
|
69
|
+
id="test-clear-1",
|
|
70
|
+
type=MessageType.USER,
|
|
71
|
+
content="Hello",
|
|
72
|
+
timestamp=datetime.now(),
|
|
73
|
+
)
|
|
74
|
+
self.chat_view.add_message(msg)
|
|
75
|
+
self.chat_view.clear_messages()
|
|
76
|
+
self.assertEqual(len(self.chat_view.messages), 0)
|
|
77
|
+
# Verify that query was called to find widgets to remove
|
|
78
|
+
self.assertTrue(mock_query.called)
|
|
79
|
+
|
|
80
|
+
def test_render_agent_message_with_syntax(self):
|
|
81
|
+
prefix = "Agent: "
|
|
82
|
+
content = "Some text\n```python\nprint('hi')\n```"
|
|
83
|
+
group = self.chat_view._render_agent_message_with_syntax(prefix, content)
|
|
84
|
+
self.assertIsNotNone(group)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
unittest.main()
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import unittest
|
|
3
|
+
from unittest.mock import MagicMock, patch
|
|
4
|
+
|
|
5
|
+
from code_puppy.config import COMMAND_HISTORY_FILE
|
|
6
|
+
from code_puppy.tui.app import CodePuppyTUI
|
|
7
|
+
from code_puppy.tui.components.custom_widgets import CustomTextArea
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestCommandHistory(unittest.TestCase):
|
|
11
|
+
def setUp(self):
|
|
12
|
+
self.app = CodePuppyTUI()
|
|
13
|
+
|
|
14
|
+
@patch("builtins.open", new_callable=unittest.mock.mock_open)
|
|
15
|
+
def test_action_send_message_saves_to_history(self, mock_open):
|
|
16
|
+
# Setup test mocks
|
|
17
|
+
self.app.query_one = MagicMock()
|
|
18
|
+
input_field_mock = MagicMock(spec=CustomTextArea)
|
|
19
|
+
input_field_mock.text = "test command"
|
|
20
|
+
self.app.query_one.return_value = input_field_mock
|
|
21
|
+
|
|
22
|
+
# Mock other methods to prevent full execution
|
|
23
|
+
self.app.add_user_message = MagicMock()
|
|
24
|
+
self.app._update_submit_cancel_button = MagicMock()
|
|
25
|
+
self.app.run_worker = MagicMock()
|
|
26
|
+
|
|
27
|
+
# Execute
|
|
28
|
+
self.app.action_send_message()
|
|
29
|
+
|
|
30
|
+
# Assertions
|
|
31
|
+
mock_open.assert_called_once_with(COMMAND_HISTORY_FILE, "a")
|
|
32
|
+
# Check that write was called with timestamped format
|
|
33
|
+
write_calls = mock_open().write.call_args_list
|
|
34
|
+
self.assertEqual(len(write_calls), 1)
|
|
35
|
+
written_content = write_calls[0][0][0]
|
|
36
|
+
# Should match pattern: \n# YYYY-MM-DDTHH:MM:SS\ntest command\n
|
|
37
|
+
self.assertTrue(
|
|
38
|
+
re.match(
|
|
39
|
+
r"^\n# \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\ntest command\n$",
|
|
40
|
+
written_content,
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
self.app.add_user_message.assert_called_once_with("test command")
|
|
44
|
+
|
|
45
|
+
@patch("builtins.open", new_callable=unittest.mock.mock_open)
|
|
46
|
+
def test_action_send_message_empty_command(self, mock_open):
|
|
47
|
+
# Setup test mocks
|
|
48
|
+
self.app.query_one = MagicMock()
|
|
49
|
+
input_field_mock = MagicMock(spec=CustomTextArea)
|
|
50
|
+
input_field_mock.text = " " # Empty or whitespace-only command
|
|
51
|
+
self.app.query_one.return_value = input_field_mock
|
|
52
|
+
|
|
53
|
+
# Mock other methods
|
|
54
|
+
self.app.add_user_message = MagicMock()
|
|
55
|
+
|
|
56
|
+
# Execute
|
|
57
|
+
self.app.action_send_message()
|
|
58
|
+
|
|
59
|
+
# Assertions - nothing should happen with empty commands
|
|
60
|
+
mock_open.assert_not_called()
|
|
61
|
+
self.app.add_user_message.assert_not_called()
|
|
62
|
+
|
|
63
|
+
@patch("builtins.open")
|
|
64
|
+
def test_action_send_message_handles_error(self, mock_open):
|
|
65
|
+
# Setup test mocks
|
|
66
|
+
self.app.query_one = MagicMock()
|
|
67
|
+
input_field_mock = MagicMock(spec=CustomTextArea)
|
|
68
|
+
input_field_mock.text = "test command"
|
|
69
|
+
self.app.query_one.return_value = input_field_mock
|
|
70
|
+
|
|
71
|
+
# Mock other methods to prevent full execution
|
|
72
|
+
self.app.add_user_message = MagicMock()
|
|
73
|
+
self.app._update_submit_cancel_button = MagicMock()
|
|
74
|
+
self.app.run_worker = MagicMock()
|
|
75
|
+
self.app.add_error_message = MagicMock()
|
|
76
|
+
|
|
77
|
+
# Make open throw an exception
|
|
78
|
+
mock_open.side_effect = Exception("File error")
|
|
79
|
+
|
|
80
|
+
# Execute
|
|
81
|
+
self.app.action_send_message()
|
|
82
|
+
|
|
83
|
+
# Assertions - error is printed to stdout, not added to UI
|
|
84
|
+
# Message should still be processed despite error saving to history
|
|
85
|
+
self.app.add_user_message.assert_called_once_with("test command")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
unittest.main()
|