code-puppy 0.0.97__py3-none-any.whl → 0.0.119__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +255 -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.119.dist-info}/METADATA +9 -2
  76. code_puppy-0.0.119.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.119.data}/data/code_puppy/models.json +0 -0
  79. {code_puppy-0.0.97.dist-info → code_puppy-0.0.119.dist-info}/WHEEL +0 -0
  80. {code_puppy-0.0.97.dist-info → code_puppy-0.0.119.dist-info}/entry_points.txt +0 -0
  81. {code_puppy-0.0.97.dist-info → code_puppy-0.0.119.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,255 @@
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_data = ModelFactory.load_config()
156
+
157
+ # Create options as (display_name, model_name) tuples
158
+ model_options = []
159
+ for model_name, model_config in models_data.items():
160
+ model_type = model_config.get("type", "unknown")
161
+ display_name = f"{model_name} ({model_type})"
162
+ model_options.append((display_name, model_name))
163
+
164
+ # Set the options on the select widget
165
+ model_select.set_options(model_options)
166
+
167
+ except Exception:
168
+ # Fallback to a basic option if loading fails
169
+ model_select.set_options([("gpt-4.1 (openai)", "gpt-4.1")])
170
+
171
+ @on(Button.Pressed, "#save-button")
172
+ def save_settings(self) -> None:
173
+ """Save the modified settings."""
174
+ from code_puppy.config import set_config_value, set_model_name
175
+
176
+ try:
177
+ # Get values from inputs
178
+ puppy_name = self.query_one("#puppy-name-input", Input).value.strip()
179
+ owner_name = self.query_one("#owner-name-input", Input).value.strip()
180
+ selected_model = self.query_one("#model-select", Select).value
181
+ yolo_mode = "true" # Always set to true in TUI mode
182
+ protected_tokens = self.query_one(
183
+ "#protected-tokens-input", Input
184
+ ).value.strip()
185
+ summary_threshold = self.query_one(
186
+ "#summary-threshold-input", Input
187
+ ).value.strip()
188
+
189
+ # Validate and save
190
+ if puppy_name:
191
+ set_config_value("puppy_name", puppy_name)
192
+ if owner_name:
193
+ set_config_value("owner_name", owner_name)
194
+
195
+ # Save model selection
196
+ if selected_model:
197
+ set_model_name(selected_model)
198
+
199
+ set_config_value("yolo_mode", yolo_mode)
200
+
201
+ # Validate and save protected tokens
202
+ if protected_tokens.isdigit():
203
+ tokens_value = int(protected_tokens)
204
+ if tokens_value >= 1000: # Minimum validation
205
+ set_config_value("protected_token_count", protected_tokens)
206
+ else:
207
+ raise ValueError("Protected tokens must be at least 1000")
208
+ elif protected_tokens: # If not empty but not digit
209
+ raise ValueError("Protected tokens must be a valid number")
210
+
211
+ # Validate and save summary threshold
212
+ if summary_threshold:
213
+ try:
214
+ threshold_value = float(summary_threshold)
215
+ if 0.1 <= threshold_value <= 0.95: # Same bounds as config function
216
+ set_config_value("summarization_threshold", summary_threshold)
217
+ else:
218
+ raise ValueError(
219
+ "Summary threshold must be between 0.1 and 0.95"
220
+ )
221
+ except ValueError as ve:
222
+ if "must be between" in str(ve):
223
+ raise ve
224
+ else:
225
+ raise ValueError(
226
+ "Summary threshold must be a valid decimal number"
227
+ )
228
+
229
+ # Return success message with model change info
230
+ message = "Settings saved successfully!"
231
+ if selected_model:
232
+ message += f" Model switched to: {selected_model}"
233
+
234
+ self.dismiss(
235
+ {
236
+ "success": True,
237
+ "message": message,
238
+ "model_changed": bool(selected_model),
239
+ }
240
+ )
241
+
242
+ except Exception as e:
243
+ self.dismiss(
244
+ {"success": False, "message": f"Error saving settings: {str(e)}"}
245
+ )
246
+
247
+ @on(Button.Pressed, "#cancel-button")
248
+ def cancel_settings(self) -> None:
249
+ """Cancel settings changes."""
250
+ self.dismiss({"success": False, "message": "Settings cancelled"})
251
+
252
+ def on_key(self, event) -> None:
253
+ """Handle key events."""
254
+ if event.key == "escape":
255
+ 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()