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
code_puppy/tui/app.py ADDED
@@ -0,0 +1,1050 @@
1
+ """
2
+ Main TUI application class.
3
+ """
4
+
5
+ from datetime import datetime, timezone
6
+
7
+ from textual import on
8
+ from textual.app import App, ComposeResult
9
+ from textual.binding import Binding
10
+ from textual.containers import Container
11
+ from textual.events import Resize
12
+ from textual.reactive import reactive
13
+ from textual.widgets import Footer, Label, ListItem, ListView
14
+
15
+ from code_puppy.agent import get_code_generation_agent, get_custom_usage_limits
16
+ from code_puppy.command_line.command_handler import handle_command
17
+ from code_puppy.config import (
18
+ get_model_name,
19
+ get_puppy_name,
20
+ initialize_command_history_file,
21
+ save_command_to_history,
22
+ )
23
+ from code_puppy.message_history_processor import (
24
+ message_history_accumulator,
25
+ prune_interrupted_tool_calls,
26
+ )
27
+
28
+ # Import our message queue system
29
+ from code_puppy.messaging import TUIRenderer, get_global_queue
30
+ from code_puppy.state_management import clear_message_history, get_message_history
31
+ from code_puppy.tui.components import (
32
+ ChatView,
33
+ CustomTextArea,
34
+ InputArea,
35
+ Sidebar,
36
+ StatusBar,
37
+ )
38
+
39
+ from .. import state_management
40
+
41
+ # Import shared message classes
42
+ from .messages import CommandSelected, HistoryEntrySelected
43
+ from .models import ChatMessage, MessageType
44
+ from .screens import HelpScreen, SettingsScreen, ToolsScreen
45
+
46
+
47
+ class CodePuppyTUI(App):
48
+ """Main Code Puppy TUI application."""
49
+
50
+ TITLE = "Code Puppy - AI Code Assistant"
51
+ SUB_TITLE = "TUI Mode"
52
+
53
+ CSS = """
54
+ Screen {
55
+ layout: horizontal;
56
+ }
57
+
58
+ #main-area {
59
+ layout: vertical;
60
+ width: 1fr;
61
+ min-width: 40;
62
+ }
63
+
64
+ #chat-container {
65
+ height: 1fr;
66
+ min-height: 10;
67
+ }
68
+ """
69
+
70
+ BINDINGS = [
71
+ Binding("ctrl+q", "quit", "Quit"),
72
+ Binding("ctrl+c", "quit", "Quit"),
73
+ Binding("ctrl+l", "clear_chat", "Clear Chat"),
74
+ Binding("ctrl+1", "show_help", "Help"),
75
+ Binding("ctrl+2", "toggle_sidebar", "History"),
76
+ Binding("ctrl+3", "open_settings", "Settings"),
77
+ Binding("ctrl+4", "show_tools", "Tools"),
78
+ Binding("ctrl+5", "focus_input", "Focus Prompt"),
79
+ Binding("ctrl+6", "focus_chat", "Focus Response"),
80
+ ]
81
+
82
+ # Reactive variables for app state
83
+ current_model = reactive("")
84
+ puppy_name = reactive("")
85
+ agent_busy = reactive(False)
86
+
87
+ def watch_agent_busy(self) -> None:
88
+ """Watch for changes to agent_busy state."""
89
+ # Update the submit/cancel button state when agent_busy changes
90
+ self._update_submit_cancel_button(self.agent_busy)
91
+
92
+ def __init__(self, initial_command: str = None, **kwargs):
93
+ super().__init__(**kwargs)
94
+ self.agent = None
95
+ self._current_worker = None
96
+ self.initial_command = initial_command
97
+
98
+ # Initialize message queue renderer
99
+ self.message_queue = get_global_queue()
100
+ self.message_renderer = TUIRenderer(self.message_queue, self)
101
+ self._renderer_started = False
102
+
103
+ def compose(self) -> ComposeResult:
104
+ """Create the UI layout."""
105
+ yield StatusBar()
106
+ yield Sidebar()
107
+ with Container(id="main-area"):
108
+ with Container(id="chat-container"):
109
+ yield ChatView(id="chat-view")
110
+ yield InputArea()
111
+ yield Footer()
112
+
113
+ def on_mount(self) -> None:
114
+ """Initialize the application when mounted."""
115
+ # Register this app instance for global access
116
+ from code_puppy.state_management import set_tui_app_instance
117
+
118
+ set_tui_app_instance(self)
119
+
120
+ # Load configuration
121
+ self.current_model = get_model_name()
122
+ self.puppy_name = get_puppy_name()
123
+
124
+ self.agent = get_code_generation_agent()
125
+
126
+ # Update status bar
127
+ status_bar = self.query_one(StatusBar)
128
+ status_bar.current_model = self.current_model
129
+ status_bar.puppy_name = self.puppy_name
130
+ status_bar.agent_status = "Ready"
131
+
132
+ # Add welcome message with YOLO mode notification
133
+ self.add_system_message(
134
+ "Welcome to Code Puppy 🐶!\n💨 YOLO mode is enabled in TUI: commands will execute without confirmation."
135
+ )
136
+
137
+ # Start the message renderer EARLY to catch startup messages
138
+ # Using call_after_refresh to start it as soon as possible after mount
139
+ self.call_after_refresh(self.start_message_renderer_sync)
140
+
141
+ # Apply responsive design adjustments
142
+ self.apply_responsive_layout()
143
+
144
+ # Auto-focus the input field so user can start typing immediately
145
+ self.call_after_refresh(self.focus_input_field)
146
+
147
+ # Process initial command if provided
148
+ if self.initial_command:
149
+ self.call_after_refresh(self.process_initial_command)
150
+
151
+ def add_system_message(
152
+ self, content: str, message_group: str = None, group_id: str = None
153
+ ) -> None:
154
+ """Add a system message to the chat."""
155
+ # Support both parameter names for backward compatibility
156
+ final_group_id = message_group or group_id
157
+ message = ChatMessage(
158
+ id=f"sys_{datetime.now(timezone.utc).timestamp()}",
159
+ type=MessageType.SYSTEM,
160
+ content=content,
161
+ timestamp=datetime.now(timezone.utc),
162
+ group_id=final_group_id,
163
+ )
164
+ chat_view = self.query_one("#chat-view", ChatView)
165
+ chat_view.add_message(message)
166
+
167
+ def add_system_message_rich(
168
+ self, rich_content, message_group: str = None, group_id: str = None
169
+ ) -> None:
170
+ """Add a system message with Rich content (like Markdown) to the chat."""
171
+ # Support both parameter names for backward compatibility
172
+ final_group_id = message_group or group_id
173
+ message = ChatMessage(
174
+ id=f"sys_rich_{datetime.now(timezone.utc).timestamp()}",
175
+ type=MessageType.SYSTEM,
176
+ content=rich_content, # Store the Rich object directly
177
+ timestamp=datetime.now(timezone.utc),
178
+ group_id=final_group_id,
179
+ )
180
+ chat_view = self.query_one("#chat-view", ChatView)
181
+ chat_view.add_message(message)
182
+
183
+ def add_user_message(self, content: str, message_group: str = None) -> None:
184
+ """Add a user message to the chat."""
185
+ message = ChatMessage(
186
+ id=f"user_{datetime.now(timezone.utc).timestamp()}",
187
+ type=MessageType.USER,
188
+ content=content,
189
+ timestamp=datetime.now(timezone.utc),
190
+ group_id=message_group,
191
+ )
192
+ chat_view = self.query_one("#chat-view", ChatView)
193
+ chat_view.add_message(message)
194
+
195
+ def add_agent_message(self, content: str, message_group: str = None) -> None:
196
+ """Add an agent message to the chat."""
197
+ message = ChatMessage(
198
+ id=f"agent_{datetime.now(timezone.utc).timestamp()}",
199
+ type=MessageType.AGENT_RESPONSE,
200
+ content=content,
201
+ timestamp=datetime.now(timezone.utc),
202
+ group_id=message_group,
203
+ )
204
+ chat_view = self.query_one("#chat-view", ChatView)
205
+ chat_view.add_message(message)
206
+
207
+ def add_error_message(self, content: str, message_group: str = None) -> None:
208
+ """Add an error message to the chat."""
209
+ message = ChatMessage(
210
+ id=f"error_{datetime.now(timezone.utc).timestamp()}",
211
+ type=MessageType.ERROR,
212
+ content=content,
213
+ timestamp=datetime.now(timezone.utc),
214
+ group_id=message_group,
215
+ )
216
+ chat_view = self.query_one("#chat-view", ChatView)
217
+ chat_view.add_message(message)
218
+
219
+ def add_agent_reasoning_message(
220
+ self, content: str, message_group: str = None
221
+ ) -> None:
222
+ """Add an agent reasoning message to the chat."""
223
+ message = ChatMessage(
224
+ id=f"agent_reasoning_{datetime.now(timezone.utc).timestamp()}",
225
+ type=MessageType.AGENT_REASONING,
226
+ content=content,
227
+ timestamp=datetime.now(timezone.utc),
228
+ group_id=message_group,
229
+ )
230
+ chat_view = self.query_one("#chat-view", ChatView)
231
+ chat_view.add_message(message)
232
+
233
+ def add_planned_next_steps_message(
234
+ self, content: str, message_group: str = None
235
+ ) -> None:
236
+ """Add an planned next steps to the chat."""
237
+ message = ChatMessage(
238
+ id=f"planned_next_steps_{datetime.now(timezone.utc).timestamp()}",
239
+ type=MessageType.PLANNED_NEXT_STEPS,
240
+ content=content,
241
+ timestamp=datetime.now(timezone.utc),
242
+ group_id=message_group,
243
+ )
244
+ chat_view = self.query_one("#chat-view", ChatView)
245
+ chat_view.add_message(message)
246
+
247
+ def on_custom_text_area_message_sent(
248
+ self, event: CustomTextArea.MessageSent
249
+ ) -> None:
250
+ """Handle message sent from custom text area."""
251
+ self.action_send_message()
252
+
253
+ def on_input_area_submit_requested(self, event) -> None:
254
+ """Handle submit button clicked."""
255
+ self.action_send_message()
256
+
257
+ def on_input_area_cancel_requested(self, event) -> None:
258
+ """Handle cancel button clicked."""
259
+ self.action_cancel_processing()
260
+
261
+ async def on_key(self, event) -> None:
262
+ """Handle app-level key events."""
263
+ input_field = self.query_one("#input-field", CustomTextArea)
264
+
265
+ # Only handle keys when input field is focused
266
+ if input_field.has_focus:
267
+ # Handle Ctrl+Enter for new lines (more reliable than Shift+Enter)
268
+ if event.key == "ctrl+enter":
269
+ input_field.insert("\\n")
270
+ event.prevent_default()
271
+ return
272
+
273
+ # Check if a modal is currently active - if so, let the modal handle keys
274
+ if hasattr(self, "_active_screen") and self._active_screen:
275
+ # Don't handle keys at the app level when a modal is active
276
+ return
277
+
278
+ # Handle arrow keys for sidebar navigation when sidebar is visible
279
+ if not input_field.has_focus:
280
+ try:
281
+ sidebar = self.query_one(Sidebar)
282
+ if sidebar.display:
283
+ # Handle navigation for the currently active tab
284
+ tabs = self.query_one("#sidebar-tabs")
285
+ active_tab = tabs.active
286
+
287
+ if active_tab == "history-tab":
288
+ history_list = self.query_one("#history-list", ListView)
289
+ if event.key == "enter":
290
+ if history_list.highlighted_child and hasattr(
291
+ history_list.highlighted_child, "command_entry"
292
+ ):
293
+ # Show command history modal
294
+ from .components.command_history_modal import (
295
+ CommandHistoryModal,
296
+ )
297
+
298
+ # Make sure sidebar's current_history_index is synced with the ListView
299
+ sidebar.current_history_index = history_list.index
300
+
301
+ # Push the modal screen
302
+ # The modal will get the command entries from the sidebar
303
+ self.push_screen(CommandHistoryModal())
304
+ event.prevent_default()
305
+ return
306
+ except Exception:
307
+ pass
308
+
309
+ def refresh_history_display(self) -> None:
310
+ """Refresh the history display with the command history file."""
311
+ try:
312
+ sidebar = self.query_one(Sidebar)
313
+ sidebar.load_command_history()
314
+ except Exception:
315
+ pass # Silently fail if history list not available
316
+
317
+ def action_send_message(self) -> None:
318
+ """Send the current message."""
319
+ input_field = self.query_one("#input-field", CustomTextArea)
320
+ message = input_field.text.strip()
321
+
322
+ if message:
323
+ # Clear input
324
+ input_field.text = ""
325
+
326
+ # Add user message to chat
327
+ self.add_user_message(message)
328
+
329
+ # Save command to history file with timestamp
330
+ try:
331
+ save_command_to_history(message)
332
+ except Exception as e:
333
+ self.add_error_message(f"Failed to save command history: {str(e)}")
334
+
335
+ # Update button state
336
+ self._update_submit_cancel_button(True)
337
+
338
+ # Process the message asynchronously using Textual's worker system
339
+ # Using exclusive=False to avoid TaskGroup conflicts with MCP servers
340
+ self._current_worker = self.run_worker(
341
+ self.process_message(message), exclusive=False
342
+ )
343
+
344
+ def _update_submit_cancel_button(self, is_cancel_mode: bool) -> None:
345
+ """Update the submit/cancel button state."""
346
+ try:
347
+ from .components.input_area import SubmitCancelButton
348
+
349
+ button = self.query_one(SubmitCancelButton)
350
+ button.is_cancel_mode = is_cancel_mode
351
+ except Exception:
352
+ pass # Silently fail if button not found
353
+
354
+ def action_cancel_processing(self) -> None:
355
+ """Cancel the current message processing."""
356
+ if hasattr(self, "_current_worker") and self._current_worker is not None:
357
+ try:
358
+ # First, kill any running shell processes (same as interactive mode Ctrl+C)
359
+ from code_puppy.tools.command_runner import (
360
+ kill_all_running_shell_processes,
361
+ )
362
+
363
+ killed = kill_all_running_shell_processes()
364
+ if killed:
365
+ self.add_system_message(
366
+ f"🔥 Cancelled {killed} running shell process(es)"
367
+ )
368
+ # Don't stop spinner/agent - let the agent continue processing
369
+ # Shell processes killed, but agent worker continues running
370
+
371
+ else:
372
+ # Only cancel the agent task if NO processes were killed
373
+ self._current_worker.cancel()
374
+ state_management._message_history = prune_interrupted_tool_calls(
375
+ state_management._message_history
376
+ )
377
+ self.add_system_message("⚠️ Processing cancelled by user")
378
+ # Stop spinner and clear state only when agent is actually cancelled
379
+ self._current_worker = None
380
+ self.agent_busy = False
381
+ self.stop_agent_progress()
382
+ except Exception as e:
383
+ self.add_error_message(f"Failed to cancel processing: {str(e)}")
384
+ # Only clear state on exception if we haven't already done so
385
+ if (
386
+ hasattr(self, "_current_worker")
387
+ and self._current_worker is not None
388
+ ):
389
+ self._current_worker = None
390
+ self.agent_busy = False
391
+ self.stop_agent_progress()
392
+
393
+ async def process_message(self, message: str) -> None:
394
+ """Process a user message asynchronously."""
395
+ try:
396
+ self.agent_busy = True
397
+ self._update_submit_cancel_button(True)
398
+ self.start_agent_progress("Thinking")
399
+
400
+ # Handle commands
401
+ if message.strip().startswith("/"):
402
+ # Handle special commands directly
403
+ if message.strip().lower() in ("clear", "/clear"):
404
+ self.action_clear_chat()
405
+ return
406
+
407
+ # Handle exit commands
408
+ if message.strip().lower() in ("/exit", "/quit"):
409
+ self.add_system_message("Goodbye!")
410
+ # Exit the application
411
+ self.app.exit()
412
+ return
413
+
414
+ # Use the existing command handler
415
+ try:
416
+ import sys
417
+ from io import StringIO
418
+
419
+ from code_puppy.tools.common import console as rich_console
420
+
421
+ # Capture the output from the command handler
422
+ old_stdout = sys.stdout
423
+ captured_output = StringIO()
424
+ sys.stdout = captured_output
425
+
426
+ # Also capture Rich console output
427
+ rich_console.file = captured_output
428
+
429
+ try:
430
+ # Call the existing command handler
431
+ result = handle_command(message.strip())
432
+ if result: # Command was handled
433
+ output = captured_output.getvalue()
434
+ if output.strip():
435
+ self.add_system_message(output.strip())
436
+ else:
437
+ self.add_system_message(f"Command '{message}' executed")
438
+ else:
439
+ self.add_system_message(f"Unknown command: {message}")
440
+ finally:
441
+ # Restore stdout and console
442
+ sys.stdout = old_stdout
443
+ rich_console.file = sys.__stdout__
444
+
445
+ except Exception as e:
446
+ self.add_error_message(f"Error executing command: {str(e)}")
447
+ return
448
+
449
+ # Process with agent
450
+ if self.agent:
451
+ try:
452
+ self.update_agent_progress("Processing", 25)
453
+
454
+ # Handle MCP servers with specific TaskGroup exception handling
455
+ try:
456
+ try:
457
+ async with self.agent.run_mcp_servers():
458
+ self.update_agent_progress("Processing", 50)
459
+ result = await self.agent.run(
460
+ message,
461
+ message_history=get_message_history(),
462
+ usage_limits=get_custom_usage_limits(),
463
+ )
464
+ except Exception as mcp_error:
465
+ # Log MCP error and fall back to running without MCP servers
466
+ self.log(f"MCP server error: {str(mcp_error)}")
467
+ self.add_system_message(
468
+ "⚠️ MCP server error, running without MCP servers"
469
+ )
470
+ result = await self.agent.run(
471
+ message,
472
+ message_history=get_message_history(),
473
+ usage_limits=get_custom_usage_limits(),
474
+ )
475
+
476
+ if not result or not hasattr(result, "output"):
477
+ self.add_error_message("Invalid response format from agent")
478
+ return
479
+
480
+ self.update_agent_progress("Processing", 75)
481
+ agent_response = result.output
482
+ self.add_agent_message(agent_response)
483
+
484
+ # Update message history
485
+ new_msgs = result.new_messages()
486
+ message_history_accumulator(new_msgs)
487
+
488
+ # Refresh history display to show new interaction
489
+ self.refresh_history_display()
490
+
491
+ except Exception as eg:
492
+ # Handle TaskGroup and other exceptions
493
+ # BaseExceptionGroup is only available in Python 3.11+
494
+ if hasattr(eg, "exceptions"):
495
+ # Handle TaskGroup exceptions specifically (Python 3.11+)
496
+ for e in eg.exceptions:
497
+ self.add_error_message(f"MCP/Agent error: {str(e)}")
498
+ else:
499
+ # Handle regular exceptions
500
+ self.add_error_message(f"MCP/Agent error: {str(eg)}")
501
+ except Exception as agent_error:
502
+ # Handle any other errors in agent processing
503
+ self.add_error_message(
504
+ f"Agent processing failed: {str(agent_error)}"
505
+ )
506
+ else:
507
+ self.add_error_message("Agent not initialized")
508
+
509
+ except Exception as e:
510
+ self.add_error_message(f"Error processing message: {str(e)}")
511
+ finally:
512
+ self.agent_busy = False
513
+ self._update_submit_cancel_button(False)
514
+ self.stop_agent_progress()
515
+
516
+ # Action methods
517
+ def action_clear_chat(self) -> None:
518
+ """Clear the chat history."""
519
+ chat_view = self.query_one("#chat-view", ChatView)
520
+ chat_view.clear_messages()
521
+ clear_message_history()
522
+ self.add_system_message("Chat history cleared")
523
+
524
+ def action_show_help(self) -> None:
525
+ """Show help information in a modal."""
526
+ self.push_screen(HelpScreen())
527
+
528
+ def action_toggle_sidebar(self) -> None:
529
+ """Toggle sidebar visibility."""
530
+ sidebar = self.query_one(Sidebar)
531
+ sidebar.display = not sidebar.display
532
+
533
+ # If sidebar is now visible, focus the history list to enable immediate keyboard navigation
534
+ if sidebar.display:
535
+ try:
536
+ # Ensure history tab is active
537
+ tabs = self.query_one("#sidebar-tabs")
538
+ tabs.active = "history-tab"
539
+
540
+ # Refresh the command history
541
+ sidebar.load_command_history()
542
+
543
+ # Focus the history list
544
+ history_list = self.query_one("#history-list", ListView)
545
+ history_list.focus()
546
+
547
+ # If the list has items, get the first item for the modal
548
+ if len(history_list.children) > 0:
549
+ # Reset sidebar's internal index tracker to 0
550
+ sidebar.current_history_index = 0
551
+
552
+ # Set ListView index to match
553
+ history_list.index = 0
554
+
555
+ # Get the first item and show the command history modal
556
+ first_item = history_list.children[0]
557
+ if hasattr(first_item, "command_entry"):
558
+ # command_entry = first_item.command_entry
559
+
560
+ # Use call_after_refresh to allow UI to update first
561
+ def show_modal():
562
+ from .components.command_history_modal import (
563
+ CommandHistoryModal,
564
+ )
565
+
566
+ # Get all command entries from the history list
567
+ command_entries = []
568
+ for i, child in enumerate(history_list.children):
569
+ if hasattr(child, "command_entry"):
570
+ command_entries.append(child.command_entry)
571
+
572
+ # Push the modal screen
573
+ # The modal will get the command entries from the sidebar
574
+ self.push_screen(CommandHistoryModal())
575
+
576
+ # Schedule modal to appear after UI refresh
577
+ self.call_after_refresh(show_modal)
578
+ except Exception as e:
579
+ # Log the exception in debug mode but silently fail for end users
580
+ import logging
581
+
582
+ logging.debug(f"Error focusing history item: {str(e)}")
583
+ pass
584
+ else:
585
+ # If sidebar is now hidden, focus the input field for a smooth workflow
586
+ try:
587
+ self.action_focus_input()
588
+ except Exception:
589
+ # Silently fail if there's an issue with focusing
590
+ pass
591
+
592
+ def action_focus_input(self) -> None:
593
+ """Focus the input field."""
594
+ input_field = self.query_one("#input-field", CustomTextArea)
595
+ input_field.focus()
596
+
597
+ def focus_input_field(self) -> None:
598
+ """Focus the input field (used for auto-focus on startup)."""
599
+ try:
600
+ input_field = self.query_one("#input-field", CustomTextArea)
601
+ input_field.focus()
602
+ except Exception:
603
+ pass # Silently handle if widget not ready yet
604
+
605
+ def action_focus_chat(self) -> None:
606
+ """Focus the chat area."""
607
+ chat_view = self.query_one("#chat-view", ChatView)
608
+ chat_view.focus()
609
+
610
+ def action_show_tools(self) -> None:
611
+ """Show the tools modal."""
612
+ self.push_screen(ToolsScreen())
613
+
614
+ def action_open_settings(self) -> None:
615
+ """Open the settings configuration screen."""
616
+
617
+ def handle_settings_result(result):
618
+ if result and result.get("success"):
619
+ # Update reactive variables
620
+ from code_puppy.config import get_model_name, get_puppy_name
621
+
622
+ self.puppy_name = get_puppy_name()
623
+
624
+ # Handle model change if needed
625
+ if result.get("model_changed"):
626
+ new_model = get_model_name()
627
+ self.current_model = new_model
628
+ # Reinitialize agent with new model
629
+ self.agent = get_code_generation_agent()
630
+
631
+ # Update status bar
632
+ status_bar = self.query_one(StatusBar)
633
+ status_bar.puppy_name = self.puppy_name
634
+ status_bar.current_model = self.current_model
635
+
636
+ # Show success message
637
+ self.add_system_message(result.get("message", "Settings updated"))
638
+ elif (
639
+ result
640
+ and not result.get("success")
641
+ and "cancelled" not in result.get("message", "").lower()
642
+ ):
643
+ # Show error message (but not for cancellation)
644
+ self.add_error_message(result.get("message", "Settings update failed"))
645
+
646
+ self.push_screen(SettingsScreen(), handle_settings_result)
647
+
648
+ def process_initial_command(self) -> None:
649
+ """Process the initial command provided when starting the TUI."""
650
+ if self.initial_command:
651
+ # Add the initial command to the input field
652
+ input_field = self.query_one("#input-field", CustomTextArea)
653
+ input_field.text = self.initial_command
654
+
655
+ # Show that we're auto-executing the initial command
656
+ self.add_system_message(
657
+ f"🚀 Auto-executing initial command: {self.initial_command}"
658
+ )
659
+
660
+ # Automatically submit the message
661
+ self.action_send_message()
662
+
663
+ # History management methods
664
+ def load_history_list(self) -> None:
665
+ """Load session history into the history tab."""
666
+ try:
667
+ from datetime import datetime, timezone
668
+
669
+ history_list = self.query_one("#history-list", ListView)
670
+
671
+ # Get history from session memory
672
+ if self.session_memory:
673
+ # Get recent history (last 24 hours by default)
674
+ recent_history = self.session_memory.get_history(within_minutes=24 * 60)
675
+
676
+ if not recent_history:
677
+ # No history available
678
+ history_list.append(
679
+ ListItem(Label("No recent history", classes="history-empty"))
680
+ )
681
+ return
682
+
683
+ # Filter out model loading entries and group history by type, display most recent first
684
+ filtered_history = [
685
+ entry
686
+ for entry in recent_history
687
+ if not entry.get("description", "").startswith("Agent loaded")
688
+ ]
689
+
690
+ # Get sidebar width for responsive text truncation
691
+ try:
692
+ sidebar_width = (
693
+ self.query_one("Sidebar").size.width
694
+ if hasattr(self.query_one("Sidebar"), "size")
695
+ else 30
696
+ )
697
+ except Exception:
698
+ sidebar_width = 30
699
+
700
+ # Adjust text length based on sidebar width
701
+ if sidebar_width >= 35:
702
+ max_text_length = 45
703
+ time_format = "%H:%M:%S"
704
+ elif sidebar_width >= 25:
705
+ max_text_length = 30
706
+ time_format = "%H:%M"
707
+ else:
708
+ max_text_length = 20
709
+ time_format = "%H:%M"
710
+
711
+ for entry in reversed(filtered_history[-20:]): # Show last 20 entries
712
+ timestamp_str = entry.get("timestamp", "")
713
+ description = entry.get("description", "Unknown task")
714
+
715
+ # Parse timestamp for display with safe parsing
716
+ def parse_timestamp_safely_for_display(timestamp_str: str) -> str:
717
+ """Parse timestamp string safely for display purposes."""
718
+ try:
719
+ # Handle 'Z' suffix (common UTC format)
720
+ cleaned_timestamp = timestamp_str.replace("Z", "+00:00")
721
+ parsed_dt = datetime.fromisoformat(cleaned_timestamp)
722
+
723
+ # If the datetime is naive (no timezone), assume UTC
724
+ if parsed_dt.tzinfo is None:
725
+ parsed_dt = parsed_dt.replace(tzinfo=timezone.utc)
726
+
727
+ return parsed_dt.strftime(time_format)
728
+ except (ValueError, AttributeError, TypeError):
729
+ # Handle invalid timestamp formats gracefully
730
+ fallback = (
731
+ timestamp_str[:5]
732
+ if sidebar_width < 25
733
+ else timestamp_str[:8]
734
+ )
735
+ return "??:??" if len(fallback) < 5 else fallback
736
+
737
+ time_display = parse_timestamp_safely_for_display(timestamp_str)
738
+
739
+ # Format description for display with responsive truncation
740
+ if description.startswith("Interactive task:"):
741
+ task_text = description[
742
+ 17:
743
+ ].strip() # Remove "Interactive task: "
744
+ truncated = task_text[:max_text_length] + (
745
+ "..." if len(task_text) > max_text_length else ""
746
+ )
747
+ display_text = f"[{time_display}] 💬 {truncated}"
748
+ css_class = "history-interactive"
749
+ elif description.startswith("TUI interaction:"):
750
+ task_text = description[
751
+ 16:
752
+ ].strip() # Remove "TUI interaction: "
753
+ truncated = task_text[:max_text_length] + (
754
+ "..." if len(task_text) > max_text_length else ""
755
+ )
756
+ display_text = f"[{time_display}] 🖥️ {truncated}"
757
+ css_class = "history-tui"
758
+ elif description.startswith("Command executed"):
759
+ cmd_text = description[
760
+ 18:
761
+ ].strip() # Remove "Command executed: "
762
+ truncated = cmd_text[: max_text_length - 5] + (
763
+ "..." if len(cmd_text) > max_text_length - 5 else ""
764
+ )
765
+ display_text = f"[{time_display}] ⚡ {truncated}"
766
+ css_class = "history-command"
767
+ else:
768
+ # Generic entry
769
+ truncated = description[:max_text_length] + (
770
+ "..." if len(description) > max_text_length else ""
771
+ )
772
+ display_text = f"[{time_display}] 📝 {truncated}"
773
+ css_class = "history-generic"
774
+
775
+ label = Label(display_text, classes=css_class)
776
+ history_item = ListItem(label)
777
+ history_item.history_entry = (
778
+ entry # Store full entry for detail view
779
+ )
780
+ history_list.append(history_item)
781
+ else:
782
+ history_list.append(
783
+ ListItem(
784
+ Label("Session memory not available", classes="history-error")
785
+ )
786
+ )
787
+
788
+ except Exception as e:
789
+ self.add_error_message(f"Failed to load history: {e}")
790
+
791
+ def show_history_details(self, history_entry: dict) -> None:
792
+ """Show detailed information about a selected history entry."""
793
+ try:
794
+ timestamp = history_entry.get("timestamp", "Unknown time")
795
+ description = history_entry.get("description", "No description")
796
+ output = history_entry.get("output", "")
797
+ awaiting_input = history_entry.get("awaiting_user_input", False)
798
+
799
+ # Parse timestamp for better display with safe parsing
800
+ def parse_timestamp_safely_for_details(timestamp_str: str) -> str:
801
+ """Parse timestamp string safely for detailed display."""
802
+ try:
803
+ # Handle 'Z' suffix (common UTC format)
804
+ cleaned_timestamp = timestamp_str.replace("Z", "+00:00")
805
+ parsed_dt = datetime.fromisoformat(cleaned_timestamp)
806
+
807
+ # If the datetime is naive (no timezone), assume UTC
808
+ if parsed_dt.tzinfo is None:
809
+ parsed_dt = parsed_dt.replace(tzinfo=timezone.utc)
810
+
811
+ return parsed_dt.strftime("%Y-%m-%d %H:%M:%S")
812
+ except (ValueError, AttributeError, TypeError):
813
+ # Handle invalid timestamp formats gracefully
814
+ return timestamp_str
815
+
816
+ formatted_time = parse_timestamp_safely_for_details(timestamp)
817
+
818
+ # Create detailed view content
819
+ details = [
820
+ f"Timestamp: {formatted_time}",
821
+ f"Description: {description}",
822
+ "",
823
+ ]
824
+
825
+ if output:
826
+ details.extend(
827
+ [
828
+ "Output:",
829
+ "─" * 40,
830
+ output,
831
+ "",
832
+ ]
833
+ )
834
+
835
+ if awaiting_input:
836
+ details.append("⚠️ Was awaiting user input")
837
+
838
+ # Display details as a system message in the chat
839
+ detail_text = "\\n".join(details)
840
+ self.add_system_message(f"History Details:\\n{detail_text}")
841
+
842
+ except Exception as e:
843
+ self.add_error_message(f"Failed to show history details: {e}")
844
+
845
+ # Progress and status methods
846
+ def set_agent_status(self, status: str, show_progress: bool = False) -> None:
847
+ """Update agent status and optionally show/hide progress bar."""
848
+ try:
849
+ # Update status bar
850
+ status_bar = self.query_one(StatusBar)
851
+ status_bar.agent_status = status
852
+
853
+ # Update spinner visibility
854
+ from .components.input_area import SimpleSpinnerWidget
855
+
856
+ spinner = self.query_one("#spinner", SimpleSpinnerWidget)
857
+ if show_progress:
858
+ spinner.add_class("visible")
859
+ spinner.display = True
860
+ spinner.start_spinning()
861
+ else:
862
+ spinner.remove_class("visible")
863
+ spinner.display = False
864
+ spinner.stop_spinning()
865
+
866
+ except Exception:
867
+ pass # Silently fail if widgets not available
868
+
869
+ def start_agent_progress(self, initial_status: str = "Thinking") -> None:
870
+ """Start showing agent progress indicators."""
871
+ self.set_agent_status(initial_status, show_progress=True)
872
+
873
+ def update_agent_progress(self, status: str, progress: int = None) -> None:
874
+ """Update agent progress during processing."""
875
+ try:
876
+ status_bar = self.query_one(StatusBar)
877
+ status_bar.agent_status = status
878
+ # Note: LoadingIndicator doesn't use progress values, it just spins
879
+ except Exception:
880
+ pass
881
+
882
+ def stop_agent_progress(self) -> None:
883
+ """Stop showing agent progress indicators."""
884
+ self.set_agent_status("Ready", show_progress=False)
885
+
886
+ def on_resize(self, event: Resize) -> None:
887
+ """Handle terminal resize events to update responsive elements."""
888
+ try:
889
+ # Apply responsive layout adjustments
890
+ self.apply_responsive_layout()
891
+
892
+ # Update status bar to reflect new width
893
+ status_bar = self.query_one(StatusBar)
894
+ status_bar.update_status()
895
+
896
+ # Refresh history display with new responsive truncation
897
+ self.refresh_history_display()
898
+
899
+ except Exception:
900
+ pass # Silently handle resize errors
901
+
902
+ def apply_responsive_layout(self) -> None:
903
+ """Apply responsive layout adjustments based on terminal size."""
904
+ try:
905
+ terminal_width = self.size.width if hasattr(self, "size") else 80
906
+ terminal_height = self.size.height if hasattr(self, "size") else 24
907
+ sidebar = self.query_one(Sidebar)
908
+
909
+ # Responsive sidebar width based on terminal width
910
+ if terminal_width >= 120:
911
+ sidebar.styles.width = 35
912
+ elif terminal_width >= 100:
913
+ sidebar.styles.width = 30
914
+ elif terminal_width >= 80:
915
+ sidebar.styles.width = 25
916
+ elif terminal_width >= 60:
917
+ sidebar.styles.width = 20
918
+ else:
919
+ sidebar.styles.width = 15
920
+
921
+ # Auto-hide sidebar on very narrow terminals
922
+ if terminal_width < 50:
923
+ if sidebar.display:
924
+ sidebar.display = False
925
+ self.add_system_message(
926
+ "💡 Sidebar auto-hidden for narrow terminal. Press Ctrl+2 to toggle."
927
+ )
928
+
929
+ # Adjust input area height for very short terminals
930
+ if terminal_height < 20:
931
+ input_area = self.query_one(InputArea)
932
+ input_area.styles.height = 7
933
+ else:
934
+ input_area = self.query_one(InputArea)
935
+ input_area.styles.height = 9
936
+
937
+ except Exception:
938
+ pass
939
+
940
+ def start_message_renderer_sync(self):
941
+ """Synchronous wrapper to start message renderer via run_worker."""
942
+ self.run_worker(self.start_message_renderer(), exclusive=False)
943
+
944
+ async def start_message_renderer(self):
945
+ """Start the message renderer to consume messages from the queue."""
946
+ if not self._renderer_started:
947
+ self._renderer_started = True
948
+
949
+ # Process any buffered startup messages first
950
+ from io import StringIO
951
+
952
+ from rich.console import Console
953
+
954
+ from code_puppy.messaging import get_buffered_startup_messages
955
+
956
+ buffered_messages = get_buffered_startup_messages()
957
+
958
+ if buffered_messages:
959
+ # Group startup messages into a single display
960
+ startup_content_lines = []
961
+
962
+ for message in buffered_messages:
963
+ try:
964
+ # Convert message content to string for grouping
965
+ if hasattr(message.content, "__rich_console__"):
966
+ # For Rich objects, render to plain text
967
+ string_io = StringIO()
968
+ # Use markup=False to prevent interpretation of square brackets as markup
969
+ temp_console = Console(
970
+ file=string_io,
971
+ width=80,
972
+ legacy_windows=False,
973
+ markup=False,
974
+ )
975
+ temp_console.print(message.content)
976
+ content_str = string_io.getvalue().rstrip("\n")
977
+ else:
978
+ content_str = str(message.content)
979
+
980
+ startup_content_lines.append(content_str)
981
+ except Exception as e:
982
+ startup_content_lines.append(
983
+ f"Error processing startup message: {e}"
984
+ )
985
+
986
+ # Create a single grouped startup message
987
+ grouped_content = "\n".join(startup_content_lines)
988
+ self.add_system_message(grouped_content)
989
+
990
+ # Clear the startup buffer after processing
991
+ self.message_queue.clear_startup_buffer()
992
+
993
+ # Now start the regular message renderer
994
+ await self.message_renderer.start()
995
+
996
+ async def stop_message_renderer(self):
997
+ """Stop the message renderer."""
998
+ if self._renderer_started:
999
+ self._renderer_started = False
1000
+ try:
1001
+ await self.message_renderer.stop()
1002
+ except Exception as e:
1003
+ # Log renderer stop errors but don't crash
1004
+ self.add_system_message(f"Renderer stop error: {e}")
1005
+
1006
+ @on(HistoryEntrySelected)
1007
+ def on_history_entry_selected(self, event: HistoryEntrySelected) -> None:
1008
+ """Handle selection of a history entry from the sidebar."""
1009
+ # Display the history entry details
1010
+ self.show_history_details(event.history_entry)
1011
+
1012
+ @on(CommandSelected)
1013
+ def on_command_selected(self, event: CommandSelected) -> None:
1014
+ """Handle selection of a command from the history modal."""
1015
+ # Set the command in the input field
1016
+ input_field = self.query_one("#input-field", CustomTextArea)
1017
+ input_field.text = event.command
1018
+
1019
+ # Focus the input field for immediate editing
1020
+ input_field.focus()
1021
+
1022
+ # Close the sidebar automatically for a smoother workflow
1023
+ sidebar = self.query_one(Sidebar)
1024
+ sidebar.display = False
1025
+
1026
+ async def on_unmount(self):
1027
+ """Clean up when the app is unmounted."""
1028
+ try:
1029
+ await self.stop_message_renderer()
1030
+ except Exception as e:
1031
+ # Log unmount errors but don't crash during cleanup
1032
+ try:
1033
+ self.add_system_message(f"Unmount cleanup error: {e}")
1034
+ except Exception:
1035
+ # If we can't even add a message, just ignore
1036
+ pass
1037
+
1038
+
1039
+ async def run_textual_ui(initial_command: str = None):
1040
+ """Run the Textual UI interface."""
1041
+ # Always enable YOLO mode in TUI mode for a smoother experience
1042
+ from code_puppy.config import set_config_value
1043
+
1044
+ # Initialize the command history file
1045
+ initialize_command_history_file()
1046
+
1047
+ set_config_value("yolo_mode", "true")
1048
+
1049
+ app = CodePuppyTUI(initial_command=initial_command)
1050
+ await app.run_async()