code-puppy 0.0.96__py3-none-any.whl → 0.0.118__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_puppy/__init__.py +2 -5
- code_puppy/__main__.py +10 -0
- code_puppy/agent.py +125 -40
- code_puppy/agent_prompts.py +30 -24
- code_puppy/callbacks.py +152 -0
- code_puppy/command_line/command_handler.py +359 -0
- code_puppy/command_line/load_context_completion.py +59 -0
- code_puppy/command_line/model_picker_completion.py +14 -21
- code_puppy/command_line/motd.py +44 -28
- code_puppy/command_line/prompt_toolkit_completion.py +42 -23
- code_puppy/config.py +266 -26
- code_puppy/http_utils.py +122 -0
- code_puppy/main.py +570 -383
- code_puppy/message_history_processor.py +195 -104
- code_puppy/messaging/__init__.py +46 -0
- code_puppy/messaging/message_queue.py +288 -0
- code_puppy/messaging/queue_console.py +293 -0
- code_puppy/messaging/renderers.py +305 -0
- code_puppy/messaging/spinner/__init__.py +55 -0
- code_puppy/messaging/spinner/console_spinner.py +200 -0
- code_puppy/messaging/spinner/spinner_base.py +66 -0
- code_puppy/messaging/spinner/textual_spinner.py +97 -0
- code_puppy/model_factory.py +73 -105
- code_puppy/plugins/__init__.py +32 -0
- code_puppy/reopenable_async_client.py +225 -0
- code_puppy/state_management.py +60 -21
- code_puppy/summarization_agent.py +56 -35
- code_puppy/token_utils.py +7 -9
- code_puppy/tools/__init__.py +1 -4
- code_puppy/tools/command_runner.py +187 -32
- code_puppy/tools/common.py +44 -35
- code_puppy/tools/file_modifications.py +335 -118
- code_puppy/tools/file_operations.py +368 -95
- code_puppy/tools/token_check.py +27 -11
- code_puppy/tools/tools_content.py +53 -0
- code_puppy/tui/__init__.py +10 -0
- code_puppy/tui/app.py +1050 -0
- code_puppy/tui/components/__init__.py +21 -0
- code_puppy/tui/components/chat_view.py +512 -0
- code_puppy/tui/components/command_history_modal.py +218 -0
- code_puppy/tui/components/copy_button.py +139 -0
- code_puppy/tui/components/custom_widgets.py +58 -0
- code_puppy/tui/components/input_area.py +167 -0
- code_puppy/tui/components/sidebar.py +309 -0
- code_puppy/tui/components/status_bar.py +182 -0
- code_puppy/tui/messages.py +27 -0
- code_puppy/tui/models/__init__.py +8 -0
- code_puppy/tui/models/chat_message.py +25 -0
- code_puppy/tui/models/command_history.py +89 -0
- code_puppy/tui/models/enums.py +24 -0
- code_puppy/tui/screens/__init__.py +13 -0
- code_puppy/tui/screens/help.py +130 -0
- code_puppy/tui/screens/settings.py +256 -0
- code_puppy/tui/screens/tools.py +74 -0
- code_puppy/tui/tests/__init__.py +1 -0
- code_puppy/tui/tests/test_chat_message.py +28 -0
- code_puppy/tui/tests/test_chat_view.py +88 -0
- code_puppy/tui/tests/test_command_history.py +89 -0
- code_puppy/tui/tests/test_copy_button.py +191 -0
- code_puppy/tui/tests/test_custom_widgets.py +27 -0
- code_puppy/tui/tests/test_disclaimer.py +27 -0
- code_puppy/tui/tests/test_enums.py +15 -0
- code_puppy/tui/tests/test_file_browser.py +60 -0
- code_puppy/tui/tests/test_help.py +38 -0
- code_puppy/tui/tests/test_history_file_reader.py +107 -0
- code_puppy/tui/tests/test_input_area.py +33 -0
- code_puppy/tui/tests/test_settings.py +44 -0
- code_puppy/tui/tests/test_sidebar.py +33 -0
- code_puppy/tui/tests/test_sidebar_history.py +153 -0
- code_puppy/tui/tests/test_sidebar_history_navigation.py +132 -0
- code_puppy/tui/tests/test_status_bar.py +54 -0
- code_puppy/tui/tests/test_timestamped_history.py +52 -0
- code_puppy/tui/tests/test_tools.py +82 -0
- code_puppy/version_checker.py +26 -3
- {code_puppy-0.0.96.dist-info → code_puppy-0.0.118.dist-info}/METADATA +9 -2
- code_puppy-0.0.118.dist-info/RECORD +86 -0
- code_puppy-0.0.96.dist-info/RECORD +0 -32
- {code_puppy-0.0.96.data → code_puppy-0.0.118.data}/data/code_puppy/models.json +0 -0
- {code_puppy-0.0.96.dist-info → code_puppy-0.0.118.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.96.dist-info → code_puppy-0.0.118.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.96.dist-info → code_puppy-0.0.118.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sidebar component with history tab.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from textual import on
|
|
8
|
+
from textual.app import ComposeResult
|
|
9
|
+
from textual.containers import Container
|
|
10
|
+
from textual.events import Key
|
|
11
|
+
from textual.widgets import Label, ListItem, ListView, TabbedContent, TabPane
|
|
12
|
+
|
|
13
|
+
from ..components.command_history_modal import CommandHistoryModal
|
|
14
|
+
|
|
15
|
+
# Import the shared message class and history reader
|
|
16
|
+
from ..models.command_history import HistoryFileReader
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Sidebar(Container):
|
|
20
|
+
"""Sidebar with session history."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, **kwargs):
|
|
23
|
+
super().__init__(**kwargs)
|
|
24
|
+
# Double-click detection variables
|
|
25
|
+
self._last_click_time = 0
|
|
26
|
+
self._last_clicked_item = None
|
|
27
|
+
self._double_click_threshold = 0.5 # 500ms for double-click
|
|
28
|
+
|
|
29
|
+
# Initialize history reader
|
|
30
|
+
self.history_reader = HistoryFileReader()
|
|
31
|
+
|
|
32
|
+
# Current index for history navigation - centralized reference
|
|
33
|
+
self.current_history_index = 0
|
|
34
|
+
self.history_entries = []
|
|
35
|
+
|
|
36
|
+
DEFAULT_CSS = """
|
|
37
|
+
Sidebar {
|
|
38
|
+
dock: left;
|
|
39
|
+
width: 30;
|
|
40
|
+
min-width: 20;
|
|
41
|
+
max-width: 50;
|
|
42
|
+
background: $surface;
|
|
43
|
+
border-right: solid $primary;
|
|
44
|
+
display: none;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#sidebar-tabs {
|
|
48
|
+
height: 1fr;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#history-list {
|
|
52
|
+
height: 1fr;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.history-interactive {
|
|
56
|
+
color: #34d399;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.history-tui {
|
|
60
|
+
color: #60a5fa;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.history-system {
|
|
64
|
+
color: #fbbf24;
|
|
65
|
+
text-style: italic;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.history-command {
|
|
69
|
+
/* Use default text color from theme */
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.history-generic {
|
|
73
|
+
color: #d1d5db;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.history-empty {
|
|
77
|
+
color: #6b7280;
|
|
78
|
+
text-style: italic;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.history-error {
|
|
82
|
+
color: #ef4444;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.file-item {
|
|
86
|
+
color: #d1d5db;
|
|
87
|
+
}
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def compose(self) -> ComposeResult:
|
|
91
|
+
"""Create the sidebar layout with tabs."""
|
|
92
|
+
with TabbedContent(id="sidebar-tabs"):
|
|
93
|
+
with TabPane("📜 History", id="history-tab"):
|
|
94
|
+
yield ListView(id="history-list")
|
|
95
|
+
|
|
96
|
+
def on_mount(self) -> None:
|
|
97
|
+
"""Initialize the sidebar when mounted."""
|
|
98
|
+
# Set up event handlers for keyboard interaction
|
|
99
|
+
history_list = self.query_one("#history-list", ListView)
|
|
100
|
+
|
|
101
|
+
# Add a class to make it focusable
|
|
102
|
+
history_list.can_focus = True
|
|
103
|
+
|
|
104
|
+
# Load command history
|
|
105
|
+
self.load_command_history()
|
|
106
|
+
|
|
107
|
+
@on(ListView.Highlighted)
|
|
108
|
+
def on_list_highlighted(self, event: ListView.Highlighted) -> None:
|
|
109
|
+
"""Handle highlighting of list items to ensure they can be selected."""
|
|
110
|
+
# This ensures the item gets focus when highlighted by arrow keys
|
|
111
|
+
if event.list_view.id == "history-list":
|
|
112
|
+
event.list_view.focus()
|
|
113
|
+
# Sync the current_history_index with the ListView index to fix modal sync issue
|
|
114
|
+
self.current_history_index = event.list_view.index
|
|
115
|
+
|
|
116
|
+
@on(ListView.Selected)
|
|
117
|
+
def on_list_selected(self, event: ListView.Selected) -> None:
|
|
118
|
+
"""Handle selection of list items (including mouse clicks).
|
|
119
|
+
|
|
120
|
+
Implements double-click detection to allow users to retrieve history items
|
|
121
|
+
by either pressing ENTER or double-clicking with the mouse.
|
|
122
|
+
"""
|
|
123
|
+
if event.list_view.id == "history-list":
|
|
124
|
+
current_time = time.time()
|
|
125
|
+
selected_item = event.item
|
|
126
|
+
|
|
127
|
+
# Check if this is a double-click
|
|
128
|
+
if (
|
|
129
|
+
selected_item == self._last_clicked_item
|
|
130
|
+
and current_time - self._last_click_time <= self._double_click_threshold
|
|
131
|
+
and hasattr(selected_item, "command_entry")
|
|
132
|
+
):
|
|
133
|
+
# Double-click detected! Show command in modal
|
|
134
|
+
# Find the index of this item
|
|
135
|
+
history_list = self.query_one("#history-list", ListView)
|
|
136
|
+
self.current_history_index = history_list.index
|
|
137
|
+
|
|
138
|
+
# Push the modal screen - it will get data from the sidebar
|
|
139
|
+
self.app.push_screen(CommandHistoryModal())
|
|
140
|
+
|
|
141
|
+
# Reset click tracking to prevent triple-click issues
|
|
142
|
+
self._last_click_time = 0
|
|
143
|
+
self._last_clicked_item = None
|
|
144
|
+
else:
|
|
145
|
+
# Single click - just update tracking
|
|
146
|
+
self._last_click_time = current_time
|
|
147
|
+
self._last_clicked_item = selected_item
|
|
148
|
+
|
|
149
|
+
@on(Key)
|
|
150
|
+
def on_key(self, event: Key) -> None:
|
|
151
|
+
"""Handle key events for the sidebar."""
|
|
152
|
+
# Handle Enter key on the history list
|
|
153
|
+
if event.key == "enter":
|
|
154
|
+
history_list = self.query_one("#history-list", ListView)
|
|
155
|
+
if (
|
|
156
|
+
history_list.has_focus
|
|
157
|
+
and history_list.highlighted_child
|
|
158
|
+
and hasattr(history_list.highlighted_child, "command_entry")
|
|
159
|
+
):
|
|
160
|
+
# Show command details in modal
|
|
161
|
+
# Update the current history index to match this item
|
|
162
|
+
self.current_history_index = history_list.index
|
|
163
|
+
|
|
164
|
+
# Push the modal screen - it will get data from the sidebar
|
|
165
|
+
self.app.push_screen(CommandHistoryModal())
|
|
166
|
+
|
|
167
|
+
# Stop propagation
|
|
168
|
+
event.stop()
|
|
169
|
+
event.prevent_default()
|
|
170
|
+
|
|
171
|
+
def load_command_history(self) -> None:
|
|
172
|
+
"""Load command history from file into the history list."""
|
|
173
|
+
try:
|
|
174
|
+
# Clear existing items
|
|
175
|
+
history_list = self.query_one("#history-list", ListView)
|
|
176
|
+
history_list.clear()
|
|
177
|
+
|
|
178
|
+
# Get command history entries (limit to last 50)
|
|
179
|
+
entries = self.history_reader.read_history(max_entries=50)
|
|
180
|
+
|
|
181
|
+
# Filter out CLI-specific commands that aren't relevant for TUI
|
|
182
|
+
cli_commands = {
|
|
183
|
+
"/help",
|
|
184
|
+
"/exit",
|
|
185
|
+
"/m",
|
|
186
|
+
"/motd",
|
|
187
|
+
"/show",
|
|
188
|
+
"/set",
|
|
189
|
+
"/tools",
|
|
190
|
+
}
|
|
191
|
+
filtered_entries = []
|
|
192
|
+
for entry in entries:
|
|
193
|
+
command = entry.get("command", "").strip()
|
|
194
|
+
# Skip CLI commands but keep everything else
|
|
195
|
+
if not any(command.startswith(cli_cmd) for cli_cmd in cli_commands):
|
|
196
|
+
filtered_entries.append(entry)
|
|
197
|
+
|
|
198
|
+
# Store filtered entries centrally
|
|
199
|
+
self.history_entries = filtered_entries
|
|
200
|
+
|
|
201
|
+
# Reset history index
|
|
202
|
+
self.current_history_index = 0
|
|
203
|
+
|
|
204
|
+
if not filtered_entries:
|
|
205
|
+
# No history available (after filtering)
|
|
206
|
+
history_list.append(
|
|
207
|
+
ListItem(Label("No command history", classes="history-empty"))
|
|
208
|
+
)
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
# Add filtered entries to the list (most recent first)
|
|
212
|
+
for entry in filtered_entries:
|
|
213
|
+
timestamp = entry["timestamp"]
|
|
214
|
+
command = entry["command"]
|
|
215
|
+
|
|
216
|
+
# Format timestamp for display
|
|
217
|
+
time_display = self.history_reader.format_timestamp(timestamp)
|
|
218
|
+
|
|
219
|
+
# Truncate command for display if needed
|
|
220
|
+
display_text = command
|
|
221
|
+
if len(display_text) > 60:
|
|
222
|
+
display_text = display_text[:57] + "..."
|
|
223
|
+
|
|
224
|
+
# Create list item
|
|
225
|
+
label = Label(
|
|
226
|
+
f"[{time_display}] {display_text}", classes="history-command"
|
|
227
|
+
)
|
|
228
|
+
list_item = ListItem(label)
|
|
229
|
+
list_item.command_entry = entry
|
|
230
|
+
history_list.append(list_item)
|
|
231
|
+
|
|
232
|
+
# Focus on the most recent command (first in the list)
|
|
233
|
+
if len(history_list.children) > 0:
|
|
234
|
+
history_list.index = 0
|
|
235
|
+
# Sync the current_history_index to match the ListView index
|
|
236
|
+
self.current_history_index = 0
|
|
237
|
+
|
|
238
|
+
# Note: We don't automatically show the modal here when just loading the history
|
|
239
|
+
# That will be handled by the app's action_toggle_sidebar method
|
|
240
|
+
# This ensures the modal only appears when explicitly opening the sidebar, not during refresh
|
|
241
|
+
|
|
242
|
+
except Exception as e:
|
|
243
|
+
# Add error item
|
|
244
|
+
history_list = self.query_one("#history-list", ListView)
|
|
245
|
+
history_list.clear()
|
|
246
|
+
history_list.append(
|
|
247
|
+
ListItem(
|
|
248
|
+
Label(f"Error loading history: {str(e)}", classes="history-error")
|
|
249
|
+
)
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def navigate_to_next_command(self) -> bool:
|
|
253
|
+
"""Navigate to the next command in history.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
bool: True if navigation succeeded, False otherwise
|
|
257
|
+
"""
|
|
258
|
+
if (
|
|
259
|
+
not self.history_entries
|
|
260
|
+
or self.current_history_index >= len(self.history_entries) - 1
|
|
261
|
+
):
|
|
262
|
+
return False
|
|
263
|
+
|
|
264
|
+
# Increment the index
|
|
265
|
+
self.current_history_index += 1
|
|
266
|
+
|
|
267
|
+
# Update the listview selection
|
|
268
|
+
try:
|
|
269
|
+
history_list = self.query_one("#history-list", ListView)
|
|
270
|
+
if history_list and self.current_history_index < len(history_list.children):
|
|
271
|
+
history_list.index = self.current_history_index
|
|
272
|
+
except Exception:
|
|
273
|
+
pass
|
|
274
|
+
|
|
275
|
+
return True
|
|
276
|
+
|
|
277
|
+
def navigate_to_previous_command(self) -> bool:
|
|
278
|
+
"""Navigate to the previous command in history.
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
bool: True if navigation succeeded, False otherwise
|
|
282
|
+
"""
|
|
283
|
+
if not self.history_entries or self.current_history_index <= 0:
|
|
284
|
+
return False
|
|
285
|
+
|
|
286
|
+
# Decrement the index
|
|
287
|
+
self.current_history_index -= 1
|
|
288
|
+
|
|
289
|
+
# Update the listview selection
|
|
290
|
+
try:
|
|
291
|
+
history_list = self.query_one("#history-list", ListView)
|
|
292
|
+
if history_list and self.current_history_index >= 0:
|
|
293
|
+
history_list.index = self.current_history_index
|
|
294
|
+
except Exception:
|
|
295
|
+
pass
|
|
296
|
+
|
|
297
|
+
return True
|
|
298
|
+
|
|
299
|
+
def get_current_command_entry(self) -> dict:
|
|
300
|
+
"""Get the current command entry based on the current index.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
dict: The current command entry or empty dict if not available
|
|
304
|
+
"""
|
|
305
|
+
if self.history_entries and 0 <= self.current_history_index < len(
|
|
306
|
+
self.history_entries
|
|
307
|
+
):
|
|
308
|
+
return self.history_entries[self.current_history_index]
|
|
309
|
+
return {"command": "", "timestamp": ""}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Status bar component for the TUI.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
from textual.app import ComposeResult
|
|
9
|
+
from textual.reactive import reactive
|
|
10
|
+
from textual.widgets import Static
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StatusBar(Static):
|
|
14
|
+
"""Status bar showing current model, puppy name, and connection status."""
|
|
15
|
+
|
|
16
|
+
DEFAULT_CSS = """
|
|
17
|
+
StatusBar {
|
|
18
|
+
dock: top;
|
|
19
|
+
height: 1;
|
|
20
|
+
background: $primary;
|
|
21
|
+
color: $text;
|
|
22
|
+
text-align: right;
|
|
23
|
+
padding: 0 1;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#status-content {
|
|
27
|
+
text-align: right;
|
|
28
|
+
width: 100%;
|
|
29
|
+
}
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
current_model = reactive("")
|
|
33
|
+
puppy_name = reactive("")
|
|
34
|
+
connection_status = reactive("Connected")
|
|
35
|
+
agent_status = reactive("Ready")
|
|
36
|
+
progress_visible = reactive(False)
|
|
37
|
+
token_count = reactive(0)
|
|
38
|
+
token_capacity = reactive(0)
|
|
39
|
+
token_proportion = reactive(0.0)
|
|
40
|
+
|
|
41
|
+
def compose(self) -> ComposeResult:
|
|
42
|
+
yield Static(id="status-content")
|
|
43
|
+
|
|
44
|
+
def watch_current_model(self) -> None:
|
|
45
|
+
self.update_status()
|
|
46
|
+
|
|
47
|
+
def watch_puppy_name(self) -> None:
|
|
48
|
+
self.update_status()
|
|
49
|
+
|
|
50
|
+
def watch_connection_status(self) -> None:
|
|
51
|
+
self.update_status()
|
|
52
|
+
|
|
53
|
+
def watch_agent_status(self) -> None:
|
|
54
|
+
self.update_status()
|
|
55
|
+
|
|
56
|
+
def watch_token_count(self) -> None:
|
|
57
|
+
self.update_status()
|
|
58
|
+
|
|
59
|
+
def watch_token_capacity(self) -> None:
|
|
60
|
+
self.update_status()
|
|
61
|
+
|
|
62
|
+
def watch_token_proportion(self) -> None:
|
|
63
|
+
self.update_status()
|
|
64
|
+
|
|
65
|
+
def watch_progress_visible(self) -> None:
|
|
66
|
+
self.update_status()
|
|
67
|
+
|
|
68
|
+
def update_status(self) -> None:
|
|
69
|
+
"""Update the status bar content with responsive design."""
|
|
70
|
+
status_widget = self.query_one("#status-content", Static)
|
|
71
|
+
|
|
72
|
+
# Get current working directory
|
|
73
|
+
cwd = os.getcwd()
|
|
74
|
+
cwd_short = os.path.basename(cwd) if cwd != "/" else "/"
|
|
75
|
+
|
|
76
|
+
# Add agent status indicator with different colors
|
|
77
|
+
if self.agent_status == "Thinking":
|
|
78
|
+
status_indicator = "🤔"
|
|
79
|
+
status_color = "yellow"
|
|
80
|
+
elif self.agent_status == "Processing":
|
|
81
|
+
status_indicator = "⚡"
|
|
82
|
+
status_color = "blue"
|
|
83
|
+
elif self.agent_status == "Busy":
|
|
84
|
+
status_indicator = "🔄"
|
|
85
|
+
status_color = "orange"
|
|
86
|
+
else: # Ready
|
|
87
|
+
status_indicator = "✅"
|
|
88
|
+
status_color = "green"
|
|
89
|
+
|
|
90
|
+
# Get terminal width for responsive content
|
|
91
|
+
try:
|
|
92
|
+
terminal_width = self.app.size.width if hasattr(self.app, "size") else 80
|
|
93
|
+
except Exception:
|
|
94
|
+
terminal_width = 80
|
|
95
|
+
|
|
96
|
+
# Create responsive status text based on terminal width
|
|
97
|
+
rich_text = Text()
|
|
98
|
+
|
|
99
|
+
# Token status with color coding
|
|
100
|
+
token_status = ""
|
|
101
|
+
token_color = "green"
|
|
102
|
+
if self.token_count > 0 and self.token_capacity > 0:
|
|
103
|
+
# Import here to avoid circular import
|
|
104
|
+
from code_puppy.config import get_summarization_threshold
|
|
105
|
+
|
|
106
|
+
summarization_threshold = get_summarization_threshold()
|
|
107
|
+
|
|
108
|
+
if self.token_proportion > summarization_threshold:
|
|
109
|
+
token_color = "red"
|
|
110
|
+
token_status = f"🔴 {self.token_count}/{self.token_capacity} ({self.token_proportion:.1%})"
|
|
111
|
+
elif self.token_proportion > (
|
|
112
|
+
summarization_threshold - 0.15
|
|
113
|
+
): # 15% before summarization threshold
|
|
114
|
+
token_color = "yellow"
|
|
115
|
+
token_status = f"🟡 {self.token_count}/{self.token_capacity} ({self.token_proportion:.1%})"
|
|
116
|
+
else:
|
|
117
|
+
token_color = "green"
|
|
118
|
+
token_status = f"🟢 {self.token_count}/{self.token_capacity} ({self.token_proportion:.1%})"
|
|
119
|
+
|
|
120
|
+
if terminal_width >= 140:
|
|
121
|
+
# Extra wide - show full path and all info including tokens
|
|
122
|
+
rich_text.append(
|
|
123
|
+
f"📁 {cwd} | 🐶 {self.puppy_name} | Model: {self.current_model} | "
|
|
124
|
+
)
|
|
125
|
+
if token_status:
|
|
126
|
+
rich_text.append(f"{token_status} | ", style=token_color)
|
|
127
|
+
rich_text.append(
|
|
128
|
+
f"{status_indicator} {self.agent_status}", style=status_color
|
|
129
|
+
)
|
|
130
|
+
elif terminal_width >= 100:
|
|
131
|
+
# Full status display for wide terminals
|
|
132
|
+
rich_text.append(
|
|
133
|
+
f"📁 {cwd_short} | 🐶 {self.puppy_name} | Model: {self.current_model} | "
|
|
134
|
+
)
|
|
135
|
+
rich_text.append(
|
|
136
|
+
f"{status_indicator} {self.agent_status}", style=status_color
|
|
137
|
+
)
|
|
138
|
+
elif terminal_width >= 120:
|
|
139
|
+
# Medium display - shorten model name if needed
|
|
140
|
+
model_display = (
|
|
141
|
+
self.current_model[:15] + "..."
|
|
142
|
+
if len(self.current_model) > 18
|
|
143
|
+
else self.current_model
|
|
144
|
+
)
|
|
145
|
+
rich_text.append(
|
|
146
|
+
f"📁 {cwd_short} | 🐶 {self.puppy_name} | {model_display} | "
|
|
147
|
+
)
|
|
148
|
+
if token_status:
|
|
149
|
+
rich_text.append(f"{token_status} | ", style=token_color)
|
|
150
|
+
rich_text.append(
|
|
151
|
+
f"{status_indicator} {self.agent_status}", style=status_color
|
|
152
|
+
)
|
|
153
|
+
elif terminal_width >= 60:
|
|
154
|
+
# Compact display - use abbreviations
|
|
155
|
+
puppy_short = (
|
|
156
|
+
self.puppy_name[:8] + "..."
|
|
157
|
+
if len(self.puppy_name) > 10
|
|
158
|
+
else self.puppy_name
|
|
159
|
+
)
|
|
160
|
+
model_short = (
|
|
161
|
+
self.current_model[:12] + "..."
|
|
162
|
+
if len(self.current_model) > 15
|
|
163
|
+
else self.current_model
|
|
164
|
+
)
|
|
165
|
+
rich_text.append(f"📁 {cwd_short} | 🐶 {puppy_short} | {model_short} | ")
|
|
166
|
+
rich_text.append(f"{status_indicator}", style=status_color)
|
|
167
|
+
else:
|
|
168
|
+
# Minimal display for very narrow terminals
|
|
169
|
+
cwd_mini = cwd_short[:8] + "..." if len(cwd_short) > 10 else cwd_short
|
|
170
|
+
rich_text.append(f"📁 {cwd_mini} | ")
|
|
171
|
+
rich_text.append(f"{status_indicator}", style=status_color)
|
|
172
|
+
|
|
173
|
+
rich_text.justify = "right"
|
|
174
|
+
status_widget.update(rich_text)
|
|
175
|
+
|
|
176
|
+
def update_token_info(
|
|
177
|
+
self, current_tokens: int, max_tokens: int, proportion: float
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Update token information in the status bar."""
|
|
180
|
+
self.token_count = current_tokens
|
|
181
|
+
self.token_capacity = max_tokens
|
|
182
|
+
self.token_proportion = proportion
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom message classes for TUI components.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from textual.message import Message
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class HistoryEntrySelected(Message):
|
|
9
|
+
"""Message sent when a history entry is selected from the sidebar."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, history_entry: dict) -> None:
|
|
12
|
+
"""Initialize with the history entry data."""
|
|
13
|
+
self.history_entry = history_entry
|
|
14
|
+
super().__init__()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CommandSelected(Message):
|
|
18
|
+
"""Message sent when a command is selected from the history modal."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, command: str) -> None:
|
|
21
|
+
"""Initialize with the command text.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
command: The command text that was selected
|
|
25
|
+
"""
|
|
26
|
+
self.command = command
|
|
27
|
+
super().__init__()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Chat message data model.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any, Dict
|
|
8
|
+
|
|
9
|
+
from .enums import MessageType
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ChatMessage:
|
|
14
|
+
"""Represents a message in the chat interface."""
|
|
15
|
+
|
|
16
|
+
id: str
|
|
17
|
+
type: MessageType
|
|
18
|
+
content: str
|
|
19
|
+
timestamp: datetime
|
|
20
|
+
metadata: Dict[str, Any] = None
|
|
21
|
+
group_id: str = None
|
|
22
|
+
|
|
23
|
+
def __post_init__(self):
|
|
24
|
+
if self.metadata is None:
|
|
25
|
+
self.metadata = {}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command history reader for TUI history tab.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Dict, List
|
|
9
|
+
|
|
10
|
+
from code_puppy.config import COMMAND_HISTORY_FILE
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HistoryFileReader:
|
|
14
|
+
"""Reads and parses the command history file for display in the TUI history tab."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, history_file_path: str = COMMAND_HISTORY_FILE):
|
|
17
|
+
"""Initialize the history file reader.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
history_file_path: Path to the command history file. Defaults to the standard location.
|
|
21
|
+
"""
|
|
22
|
+
self.history_file_path = history_file_path
|
|
23
|
+
self._timestamp_pattern = re.compile(
|
|
24
|
+
r"^# (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def read_history(self, max_entries: int = 100) -> List[Dict[str, str]]:
|
|
28
|
+
"""Read command history from the history file.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
max_entries: Maximum number of entries to read. Defaults to 100.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
List of history entries with timestamp and command, most recent first.
|
|
35
|
+
"""
|
|
36
|
+
if not os.path.exists(self.history_file_path):
|
|
37
|
+
return []
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
with open(self.history_file_path, "r") as f:
|
|
41
|
+
content = f.read()
|
|
42
|
+
|
|
43
|
+
# Split content by timestamp marker
|
|
44
|
+
raw_chunks = re.split(r"(# \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})", content)
|
|
45
|
+
|
|
46
|
+
# Filter out empty chunks
|
|
47
|
+
chunks = [chunk for chunk in raw_chunks if chunk.strip()]
|
|
48
|
+
|
|
49
|
+
entries = []
|
|
50
|
+
|
|
51
|
+
# Process chunks in pairs (timestamp and command)
|
|
52
|
+
i = 0
|
|
53
|
+
while i < len(chunks) - 1:
|
|
54
|
+
if self._timestamp_pattern.match(chunks[i]):
|
|
55
|
+
timestamp = self._timestamp_pattern.match(chunks[i]).group(1)
|
|
56
|
+
command_text = chunks[i + 1].strip()
|
|
57
|
+
|
|
58
|
+
if command_text: # Skip empty commands
|
|
59
|
+
entries.append(
|
|
60
|
+
{"timestamp": timestamp, "command": command_text}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
i += 2
|
|
64
|
+
else:
|
|
65
|
+
# Skip invalid chunks
|
|
66
|
+
i += 1
|
|
67
|
+
|
|
68
|
+
# Limit the number of entries and reverse to get most recent first
|
|
69
|
+
return entries[-max_entries:][::-1]
|
|
70
|
+
|
|
71
|
+
except Exception:
|
|
72
|
+
# Return empty list on any error
|
|
73
|
+
return []
|
|
74
|
+
|
|
75
|
+
def format_timestamp(self, timestamp: str, format_str: str = "%H:%M:%S") -> str:
|
|
76
|
+
"""Format a timestamp string for display.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
timestamp: ISO format timestamp string (YYYY-MM-DDThh:mm:ss)
|
|
80
|
+
format_str: Format string for datetime.strftime
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Formatted timestamp string
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
dt = datetime.fromisoformat(timestamp)
|
|
87
|
+
return dt.strftime(format_str)
|
|
88
|
+
except (ValueError, TypeError):
|
|
89
|
+
return timestamp
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Enums for the TUI module.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MessageType(Enum):
|
|
9
|
+
"""Types of messages in the chat interface."""
|
|
10
|
+
|
|
11
|
+
USER = "user"
|
|
12
|
+
AGENT = "agent"
|
|
13
|
+
SYSTEM = "system"
|
|
14
|
+
ERROR = "error"
|
|
15
|
+
DIVIDER = "divider"
|
|
16
|
+
INFO = "info"
|
|
17
|
+
SUCCESS = "success"
|
|
18
|
+
WARNING = "warning"
|
|
19
|
+
TOOL_OUTPUT = "tool_output"
|
|
20
|
+
COMMAND_OUTPUT = "command_output"
|
|
21
|
+
|
|
22
|
+
AGENT_REASONING = "agent_reasoning"
|
|
23
|
+
PLANNED_NEXT_STEPS = "planned_next_steps"
|
|
24
|
+
AGENT_RESPONSE = "agent_response"
|