code-puppy 0.0.96__py3-none-any.whl → 0.0.118__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_puppy/__init__.py +2 -5
- code_puppy/__main__.py +10 -0
- code_puppy/agent.py +125 -40
- code_puppy/agent_prompts.py +30 -24
- code_puppy/callbacks.py +152 -0
- code_puppy/command_line/command_handler.py +359 -0
- code_puppy/command_line/load_context_completion.py +59 -0
- code_puppy/command_line/model_picker_completion.py +14 -21
- code_puppy/command_line/motd.py +44 -28
- code_puppy/command_line/prompt_toolkit_completion.py +42 -23
- code_puppy/config.py +266 -26
- code_puppy/http_utils.py +122 -0
- code_puppy/main.py +570 -383
- code_puppy/message_history_processor.py +195 -104
- code_puppy/messaging/__init__.py +46 -0
- code_puppy/messaging/message_queue.py +288 -0
- code_puppy/messaging/queue_console.py +293 -0
- code_puppy/messaging/renderers.py +305 -0
- code_puppy/messaging/spinner/__init__.py +55 -0
- code_puppy/messaging/spinner/console_spinner.py +200 -0
- code_puppy/messaging/spinner/spinner_base.py +66 -0
- code_puppy/messaging/spinner/textual_spinner.py +97 -0
- code_puppy/model_factory.py +73 -105
- code_puppy/plugins/__init__.py +32 -0
- code_puppy/reopenable_async_client.py +225 -0
- code_puppy/state_management.py +60 -21
- code_puppy/summarization_agent.py +56 -35
- code_puppy/token_utils.py +7 -9
- code_puppy/tools/__init__.py +1 -4
- code_puppy/tools/command_runner.py +187 -32
- code_puppy/tools/common.py +44 -35
- code_puppy/tools/file_modifications.py +335 -118
- code_puppy/tools/file_operations.py +368 -95
- code_puppy/tools/token_check.py +27 -11
- code_puppy/tools/tools_content.py +53 -0
- code_puppy/tui/__init__.py +10 -0
- code_puppy/tui/app.py +1050 -0
- code_puppy/tui/components/__init__.py +21 -0
- code_puppy/tui/components/chat_view.py +512 -0
- code_puppy/tui/components/command_history_modal.py +218 -0
- code_puppy/tui/components/copy_button.py +139 -0
- code_puppy/tui/components/custom_widgets.py +58 -0
- code_puppy/tui/components/input_area.py +167 -0
- code_puppy/tui/components/sidebar.py +309 -0
- code_puppy/tui/components/status_bar.py +182 -0
- code_puppy/tui/messages.py +27 -0
- code_puppy/tui/models/__init__.py +8 -0
- code_puppy/tui/models/chat_message.py +25 -0
- code_puppy/tui/models/command_history.py +89 -0
- code_puppy/tui/models/enums.py +24 -0
- code_puppy/tui/screens/__init__.py +13 -0
- code_puppy/tui/screens/help.py +130 -0
- code_puppy/tui/screens/settings.py +256 -0
- code_puppy/tui/screens/tools.py +74 -0
- code_puppy/tui/tests/__init__.py +1 -0
- code_puppy/tui/tests/test_chat_message.py +28 -0
- code_puppy/tui/tests/test_chat_view.py +88 -0
- code_puppy/tui/tests/test_command_history.py +89 -0
- code_puppy/tui/tests/test_copy_button.py +191 -0
- code_puppy/tui/tests/test_custom_widgets.py +27 -0
- code_puppy/tui/tests/test_disclaimer.py +27 -0
- code_puppy/tui/tests/test_enums.py +15 -0
- code_puppy/tui/tests/test_file_browser.py +60 -0
- code_puppy/tui/tests/test_help.py +38 -0
- code_puppy/tui/tests/test_history_file_reader.py +107 -0
- code_puppy/tui/tests/test_input_area.py +33 -0
- code_puppy/tui/tests/test_settings.py +44 -0
- code_puppy/tui/tests/test_sidebar.py +33 -0
- code_puppy/tui/tests/test_sidebar_history.py +153 -0
- code_puppy/tui/tests/test_sidebar_history_navigation.py +132 -0
- code_puppy/tui/tests/test_status_bar.py +54 -0
- code_puppy/tui/tests/test_timestamped_history.py +52 -0
- code_puppy/tui/tests/test_tools.py +82 -0
- code_puppy/version_checker.py +26 -3
- {code_puppy-0.0.96.dist-info → code_puppy-0.0.118.dist-info}/METADATA +9 -2
- code_puppy-0.0.118.dist-info/RECORD +86 -0
- code_puppy-0.0.96.dist-info/RECORD +0 -32
- {code_puppy-0.0.96.data → code_puppy-0.0.118.data}/data/code_puppy/models.json +0 -0
- {code_puppy-0.0.96.dist-info → code_puppy-0.0.118.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.96.dist-info → code_puppy-0.0.118.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.96.dist-info → code_puppy-0.0.118.dist-info}/licenses/LICENSE +0 -0
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()
|