chat-console 0.1.1__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.
@@ -0,0 +1,345 @@
1
+ from typing import List, Dict, Any, Optional, Callable, Awaitable
2
+ import time
3
+ import asyncio
4
+ from datetime import datetime
5
+ import re
6
+
7
+ from textual.app import ComposeResult
8
+ from textual.containers import Container, ScrollableContainer, Vertical
9
+ from textual.reactive import reactive
10
+ from textual.widgets import Button, Input, Label, Static
11
+ from textual.widget import Widget
12
+ from textual.widgets import RichLog
13
+ from textual.message import Message
14
+
15
+ from ..models import Message, Conversation
16
+ from ..api.base import BaseModelClient
17
+ from ..config import CONFIG
18
+
19
+ class MessageDisplay(RichLog):
20
+ """Widget to display a single message"""
21
+
22
+ DEFAULT_CSS = """
23
+ MessageDisplay {
24
+ width: 100%;
25
+ height: auto;
26
+ margin: 1 0;
27
+ overflow: auto;
28
+ padding: 1;
29
+ }
30
+
31
+ MessageDisplay.user-message {
32
+ background: $primary-darken-2;
33
+ border-right: wide $primary;
34
+ margin-right: 4;
35
+ }
36
+
37
+ MessageDisplay.assistant-message {
38
+ background: $surface;
39
+ border-left: wide $secondary;
40
+ margin-left: 4;
41
+ }
42
+
43
+ MessageDisplay.system-message {
44
+ background: $surface-darken-1;
45
+ border: dashed $primary-background;
46
+ margin: 1 4;
47
+ }
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ message: Message,
53
+ highlight_code: bool = True,
54
+ name: Optional[str] = None
55
+ ):
56
+ super().__init__(
57
+ highlight=True,
58
+ markup=True,
59
+ wrap=True,
60
+ name=name
61
+ )
62
+ self.message = message
63
+ self.highlight_code = highlight_code
64
+
65
+ def on_mount(self) -> None:
66
+ """Handle mount event"""
67
+ # Add message type class
68
+ if self.message.role == "user":
69
+ self.add_class("user-message")
70
+ elif self.message.role == "assistant":
71
+ self.add_class("assistant-message")
72
+ elif self.message.role == "system":
73
+ self.add_class("system-message")
74
+
75
+ # Initial content
76
+ self.write(self._format_content(self.message.content))
77
+
78
+ def update_content(self, content: str) -> None:
79
+ """Update the message content"""
80
+ self.message.content = content
81
+ self.clear()
82
+ self.write(self._format_content(content))
83
+ # Force a refresh after writing
84
+ self.refresh(layout=True)
85
+
86
+ def _format_content(self, content: str) -> str:
87
+ """Format message content with timestamp"""
88
+ timestamp = datetime.now().strftime("%H:%M")
89
+ return f"[dim]{timestamp}[/dim] {content}"
90
+
91
+ class InputWithFocus(Input):
92
+ """Enhanced Input that better handles focus and maintains cursor position"""
93
+
94
+ def on_key(self, event) -> None:
95
+ """Custom key handling for input"""
96
+ # Let control keys pass through
97
+ if event.is_control:
98
+ return super().on_key(event)
99
+
100
+ # Handle Enter key
101
+ if event.key == "enter":
102
+ self.post_message(self.Submitted(self))
103
+ return
104
+
105
+ # Normal input handling for other keys
106
+ super().on_key(event)
107
+
108
+ class ChatInterface(Container):
109
+ """Main chat interface container"""
110
+
111
+ DEFAULT_CSS = """
112
+ ChatInterface {
113
+ width: 100%;
114
+ height: 100%;
115
+ background: $surface;
116
+ }
117
+
118
+ #messages-container {
119
+ width: 100%;
120
+ height: 1fr;
121
+ min-height: 10;
122
+ border-bottom: solid $primary-darken-2;
123
+ overflow: auto;
124
+ padding: 0 1;
125
+ }
126
+
127
+ #input-area {
128
+ width: 100%;
129
+ height: auto;
130
+ min-height: 4;
131
+ max-height: 10;
132
+ padding: 1;
133
+ }
134
+
135
+ #message-input {
136
+ width: 1fr;
137
+ min-height: 2;
138
+ height: auto;
139
+ margin-right: 1;
140
+ border: solid $primary-darken-2;
141
+ }
142
+
143
+ #message-input:focus {
144
+ border: solid $primary;
145
+ }
146
+
147
+ #send-button {
148
+ width: auto;
149
+ min-width: 8;
150
+ height: 2;
151
+ }
152
+
153
+ #loading-indicator {
154
+ width: 100%;
155
+ height: 1;
156
+ background: $primary-darken-1;
157
+ color: $text;
158
+ display: none;
159
+ padding: 0 1;
160
+ }
161
+ """
162
+
163
+ class MessageSent(Message):
164
+ """Sent when a message is sent"""
165
+ def __init__(self, content: str):
166
+ self.content = content
167
+ super().__init__()
168
+
169
+ class StopGeneration(Message):
170
+ """Sent when generation should be stopped"""
171
+
172
+ conversation = reactive(None)
173
+ is_loading = reactive(False)
174
+
175
+ def __init__(
176
+ self,
177
+ conversation: Optional[Conversation] = None,
178
+ name: Optional[str] = None,
179
+ id: Optional[str] = None
180
+ ):
181
+ super().__init__(name=name, id=id)
182
+ self.conversation = conversation
183
+ self.messages: List[Message] = []
184
+ self.current_message_display = None
185
+ if conversation and conversation.messages:
186
+ self.messages = conversation.messages
187
+
188
+ def compose(self) -> ComposeResult:
189
+ """Compose the chat interface"""
190
+ # Messages area
191
+ with ScrollableContainer(id="messages-container"):
192
+ for message in self.messages:
193
+ yield MessageDisplay(message, highlight_code=CONFIG["highlight_code"])
194
+
195
+ # Input area with loading indicator and controls
196
+ with Container(id="input-area"):
197
+ yield Container(
198
+ Label("Generating response...", id="loading-text"),
199
+ id="loading-indicator"
200
+ )
201
+ yield Container(
202
+ InputWithFocus(placeholder="Type your message here...", id="message-input"),
203
+ Button("Send", id="send-button", variant="primary"),
204
+ id="controls"
205
+ )
206
+
207
+ def on_mount(self) -> None:
208
+ """Initialize on mount"""
209
+ # Scroll to bottom initially
210
+ self.scroll_to_bottom()
211
+
212
+ def _request_focus(self) -> None:
213
+ """Request focus for the input field"""
214
+ try:
215
+ input_field = self.query_one("#message-input")
216
+ if input_field and not input_field.has_focus:
217
+ # Only focus if not already focused and no other widget has focus
218
+ if not self.app.focused or self.app.focused.id == "message-input":
219
+ self.app.set_focus(input_field)
220
+ except Exception:
221
+ pass
222
+
223
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
224
+ """Handle button presses"""
225
+ button_id = event.button.id
226
+
227
+ if button_id == "send-button":
228
+ await self.send_message()
229
+
230
+ async def on_input_submitted(self, event: Input.Submitted) -> None:
231
+ """Handle input submission"""
232
+ if event.input.id == "message-input":
233
+ await self.send_message()
234
+
235
+ async def add_message(self, role: str, content: str, update_last: bool = False) -> None:
236
+ """Add or update a message in the chat"""
237
+ if update_last and self.current_message_display and role == "assistant":
238
+ # Update existing message
239
+ await self.current_message_display.update_content(content)
240
+ else:
241
+ # Add new message
242
+ message = Message(role=role, content=content)
243
+ self.messages.append(message)
244
+ messages_container = self.query_one("#messages-container")
245
+ self.current_message_display = MessageDisplay(
246
+ message,
247
+ highlight_code=CONFIG["highlight_code"]
248
+ )
249
+ messages_container.mount(self.current_message_display)
250
+
251
+ self.scroll_to_bottom()
252
+
253
+ async def send_message(self) -> None:
254
+ """Send a message"""
255
+ input_widget = self.query_one("#message-input")
256
+ content = input_widget.value.strip()
257
+
258
+ if not content:
259
+ return
260
+
261
+ # Clear input
262
+ input_widget.value = ""
263
+
264
+ # Add user message to chat
265
+ await self.add_message("user", content)
266
+
267
+ # Reset current message display for next assistant response
268
+ self.current_message_display = None
269
+
270
+ # Emit message sent event
271
+ self.post_message(self.MessageSent(content))
272
+
273
+ # Re-focus the input after sending if it was focused before
274
+ if input_widget.has_focus:
275
+ input_widget.focus()
276
+
277
+ def start_loading(self) -> None:
278
+ """Show loading indicator"""
279
+ self.is_loading = True
280
+ loading = self.query_one("#loading-indicator")
281
+ loading.display = True
282
+
283
+ def stop_loading(self) -> None:
284
+ """Hide loading indicator"""
285
+ self.is_loading = False
286
+ loading = self.query_one("#loading-indicator")
287
+ loading.display = False
288
+
289
+ def clear_messages(self) -> None:
290
+ """Clear all messages"""
291
+ self.messages = []
292
+ self.current_message_display = None
293
+ messages_container = self.query_one("#messages-container")
294
+ messages_container.remove_children()
295
+
296
+ async def set_conversation(self, conversation: Conversation) -> None:
297
+ """Set the current conversation"""
298
+ self.conversation = conversation
299
+ self.messages = conversation.messages if conversation else []
300
+ self.current_message_display = None
301
+
302
+ # Update UI
303
+ messages_container = self.query_one("#messages-container")
304
+ messages_container.remove_children()
305
+
306
+ if self.messages:
307
+ # Mount messages with a small delay between each
308
+ for message in self.messages:
309
+ display = MessageDisplay(message, highlight_code=CONFIG["highlight_code"])
310
+ messages_container.mount(display)
311
+ self.scroll_to_bottom()
312
+ await asyncio.sleep(0.01) # Small delay to prevent UI freezing
313
+
314
+ self.scroll_to_bottom()
315
+
316
+ # Re-focus the input field after changing conversation
317
+ self.query_one("#message-input").focus()
318
+
319
+ def on_resize(self, event) -> None:
320
+ """Handle terminal resize events"""
321
+ try:
322
+ # Re-focus the input if it lost focus during resize
323
+ self.query_one("#message-input").focus()
324
+
325
+ # Scroll to bottom to ensure the latest messages are visible
326
+ self.scroll_to_bottom()
327
+ except Exception:
328
+ # Ignore errors during resize handling
329
+ pass
330
+
331
+ def scroll_to_bottom(self) -> None:
332
+ """Scroll to the bottom of the messages container"""
333
+ try:
334
+ messages_container = self.query_one("#messages-container")
335
+ messages_container.scroll_end(animate=False)
336
+ except Exception:
337
+ # Container might not be available yet or scroll_end might not work
338
+ pass
339
+
340
+ def watch_is_loading(self, is_loading: bool) -> None:
341
+ """Watch the is_loading property"""
342
+ if is_loading:
343
+ self.start_loading()
344
+ else:
345
+ self.stop_loading()
app/ui/chat_list.py ADDED
@@ -0,0 +1,336 @@
1
+ from typing import List, Dict, Any, Optional, Callable
2
+ from datetime import datetime
3
+ import time
4
+
5
+ from textual.app import ComposeResult
6
+ from textual.containers import Container, ScrollableContainer
7
+ from textual.reactive import reactive
8
+ from textual.widgets import Button, Input, Label, Static
9
+ from textual.widget import Widget
10
+ from textual.message import Message
11
+
12
+ from ..models import Conversation
13
+ from ..database import ChatDatabase
14
+ from ..config import CONFIG
15
+
16
+ class ChatListItem(Static):
17
+ """Widget to display a single chat in the list"""
18
+
19
+ DEFAULT_CSS = """
20
+ ChatListItem {
21
+ width: 100%;
22
+ height: 3;
23
+ padding: 0 1;
24
+ border-bottom: solid $primary-darken-3;
25
+ }
26
+
27
+ ChatListItem:hover {
28
+ background: $primary-darken-2;
29
+ }
30
+
31
+ ChatListItem.selected {
32
+ background: $primary-darken-1;
33
+ border-left: wide $primary;
34
+ }
35
+
36
+ .chat-title {
37
+ width: 100%;
38
+ content-align: center middle;
39
+ text-align: left;
40
+ }
41
+
42
+ .chat-model {
43
+ width: 100%;
44
+ color: $text-muted;
45
+ text-align: right;
46
+ }
47
+
48
+ .chat-date {
49
+ width: 100%;
50
+ color: $text-muted;
51
+ text-align: right;
52
+ text-style: italic;
53
+ }
54
+ """
55
+
56
+ is_selected = reactive(False)
57
+
58
+ class ChatSelected(Message):
59
+ """Event sent when a chat is selected"""
60
+ def __init__(self, conversation_id: int):
61
+ self.conversation_id = conversation_id
62
+ super().__init__()
63
+
64
+ def __init__(
65
+ self,
66
+ conversation: Conversation,
67
+ is_selected: bool = False,
68
+ name: Optional[str] = None
69
+ ):
70
+ super().__init__(name=name)
71
+ self.conversation = conversation
72
+ self.is_selected = is_selected
73
+
74
+ def compose(self) -> ComposeResult:
75
+ """Set up the chat list item"""
76
+ yield Label(self.conversation.title, classes="chat-title")
77
+
78
+ model_display = self.conversation.model
79
+ if model_display in CONFIG["available_models"]:
80
+ model_display = CONFIG["available_models"][model_display]["display_name"]
81
+
82
+ yield Label(model_display, classes="chat-model")
83
+
84
+ # Format date
85
+ updated_at = self.conversation.updated_at
86
+ if updated_at:
87
+ try:
88
+ dt = datetime.fromisoformat(updated_at)
89
+ formatted_date = dt.strftime("%Y-%m-%d %H:%M")
90
+ except:
91
+ formatted_date = updated_at
92
+ else:
93
+ formatted_date = "Unknown"
94
+
95
+ yield Label(formatted_date, classes="chat-date")
96
+
97
+ def on_click(self) -> None:
98
+ """Handle click events"""
99
+ self.post_message(self.ChatSelected(self.conversation.id))
100
+
101
+ def watch_is_selected(self, is_selected: bool) -> None:
102
+ """Watch the is_selected property"""
103
+ if is_selected:
104
+ self.add_class("selected")
105
+ else:
106
+ self.remove_class("selected")
107
+
108
+ class ChatList(Container):
109
+ """Widget to display the list of chats"""
110
+
111
+ DEFAULT_CSS = """
112
+ ChatList {
113
+ width: 100%;
114
+ height: 100%;
115
+ background: $surface-darken-1;
116
+ }
117
+
118
+ #chat-list-header {
119
+ width: 100%;
120
+ height: 3;
121
+ background: $primary-darken-1;
122
+ color: $text;
123
+ content-align: center middle;
124
+ text-align: center;
125
+ }
126
+
127
+ #chat-list-container {
128
+ width: 100%;
129
+ height: 1fr;
130
+ }
131
+
132
+ #new-chat-button {
133
+ width: 100%;
134
+ height: 3;
135
+ background: $success;
136
+ color: $text;
137
+ border: none;
138
+ }
139
+
140
+ #loading-indicator {
141
+ width: 100%;
142
+ height: 1;
143
+ background: $primary-darken-1;
144
+ color: $text;
145
+ display: none;
146
+ }
147
+
148
+ #no-chats-label {
149
+ width: 100%;
150
+ height: 3;
151
+ color: $text-muted;
152
+ content-align: center middle;
153
+ text-align: center;
154
+ }
155
+ """
156
+
157
+ conversations = reactive([])
158
+ selected_id = reactive(-1)
159
+ is_loading = reactive(False)
160
+
161
+ class NewChatRequested(Message):
162
+ """Event sent when a new chat is requested"""
163
+
164
+ class ChatSelected(Message):
165
+ """Event sent when a chat is selected"""
166
+ def __init__(self, conversation: Conversation):
167
+ self.conversation = conversation
168
+ super().__init__()
169
+
170
+ def __init__(
171
+ self,
172
+ db: ChatDatabase,
173
+ name: Optional[str] = None,
174
+ id: Optional[str] = None
175
+ ):
176
+ super().__init__(name=name, id=id)
177
+ self.db = db
178
+
179
+ def compose(self) -> ComposeResult:
180
+ """Set up the chat list"""
181
+ yield Label("Chat History", id="chat-list-header")
182
+
183
+ with ScrollableContainer(id="chat-list-container"):
184
+ yield Label("No conversations yet.", id="no-chats-label")
185
+
186
+ with Container(id="loading-indicator"):
187
+ yield Label("Loading...", id="loading-text")
188
+
189
+ yield Button("+ New Chat", id="new-chat-button")
190
+
191
+ def on_mount(self) -> None:
192
+ """Load chats when mounted"""
193
+ self.load_conversations()
194
+
195
+ def on_button_pressed(self, event: Button.Pressed) -> None:
196
+ """Handle button presses"""
197
+ button_id = event.button.id
198
+
199
+ if button_id == "new-chat-button":
200
+ self.post_message(self.NewChatRequested())
201
+ # Return focus to chat input after a short delay
202
+ self.set_timer(0.1, self._return_focus_to_chat)
203
+
204
+ def load_conversations(self) -> None:
205
+ """Load conversations from database"""
206
+ # Only proceed if we're properly mounted
207
+ if not self.is_mounted:
208
+ return
209
+
210
+ self.is_loading = True
211
+
212
+ try:
213
+ # Make sure the container exists before attempting to update UI
214
+ if not self.query("#chat-list-container"):
215
+ return
216
+
217
+ conversations = self.db.get_all_conversations(
218
+ limit=CONFIG["max_history_items"]
219
+ )
220
+ self.conversations = [Conversation.from_dict(c) for c in conversations]
221
+
222
+ # Only update UI if we're still mounted
223
+ if self.is_mounted:
224
+ self._update_list_ui()
225
+ except Exception as e:
226
+ # Silently handle errors during startup
227
+ pass
228
+ finally:
229
+ self.is_loading = False
230
+
231
+ def _update_list_ui(self) -> None:
232
+ """Update the UI with current conversations"""
233
+ try:
234
+ # Only proceed if we're properly mounted
235
+ if not self.is_mounted:
236
+ return
237
+
238
+ # Get the container safely
239
+ container = self.query_one("#chat-list-container", ScrollableContainer)
240
+
241
+ # Safely remove existing children
242
+ try:
243
+ container.remove_children()
244
+ except Exception:
245
+ # If remove_children fails, continue anyway
246
+ pass
247
+
248
+ if not self.conversations:
249
+ container.mount(Label("No conversations yet.", id="no-chats-label"))
250
+ return
251
+
252
+ # Mount items directly without batch_update
253
+ for conversation in self.conversations:
254
+ is_selected = conversation.id == self.selected_id
255
+ container.mount(ChatListItem(conversation, is_selected=is_selected))
256
+ except Exception as e:
257
+ # Silently handle errors during UI updates
258
+ # These can occur during app initialization/shutdown
259
+ pass
260
+
261
+ def on_chat_list_item_chat_selected(self, event: ChatListItem.ChatSelected) -> None:
262
+ """Handle chat selection"""
263
+ self.selected_id = event.conversation_id
264
+
265
+ # Find the selected conversation
266
+ selected_conversation = None
267
+ for conv in self.conversations:
268
+ if conv.id == self.selected_id:
269
+ selected_conversation = conv
270
+ break
271
+
272
+ if selected_conversation:
273
+ # Get full conversation with messages
274
+ full_conversation = self.db.get_conversation(self.selected_id)
275
+ if full_conversation:
276
+ self.post_message(self.ChatSelected(
277
+ Conversation.from_dict(full_conversation)
278
+ ))
279
+ # Return focus to chat input after a short delay
280
+ self.set_timer(0.1, self._return_focus_to_chat)
281
+
282
+ def _return_focus_to_chat(self) -> None:
283
+ """Helper to return focus to chat input"""
284
+ try:
285
+ from .chat_interface import ChatInterface
286
+ chat_interface = self.app.query_one("#chat-interface", expect_type=ChatInterface)
287
+ if chat_interface:
288
+ input_field = chat_interface.query_one("#message-input")
289
+ if input_field and not input_field.has_focus:
290
+ self.app.set_focus(input_field)
291
+ except Exception:
292
+ pass
293
+
294
+ def refresh(self, layout: bool = False, **kwargs) -> None:
295
+ """Refresh the conversation list"""
296
+ # Don't call load_conversations() directly to avoid recursion
297
+ if not kwargs.get("_skip_load", False):
298
+ try:
299
+ # Check if container exists before trying to update UI
300
+ self.query_one("#chat-list-container", ScrollableContainer)
301
+
302
+ conversations = self.db.get_all_conversations(
303
+ limit=CONFIG["max_history_items"]
304
+ )
305
+ self.conversations = [Conversation.from_dict(c) for c in conversations]
306
+
307
+ # Only update UI if we're properly mounted
308
+ if self.is_mounted:
309
+ self._update_list_ui()
310
+ except Exception as e:
311
+ # Might occur during initialization when container is not yet available
312
+ # Don't print error as it's expected during startup
313
+ pass
314
+
315
+ # Let parent handle layout changes but don't call super().refresh()
316
+ # which would cause infinite recursion
317
+
318
+ def watch_is_loading(self, is_loading: bool) -> None:
319
+ """Watch the is_loading property"""
320
+ loading = self.query_one("#loading-indicator")
321
+ loading.display = True if is_loading else False
322
+
323
+ def watch_selected_id(self, selected_id: int) -> None:
324
+ """Watch the selected_id property"""
325
+ try:
326
+ # Get container first to avoid repeating the query
327
+ container = self.query_one("#chat-list-container", ScrollableContainer)
328
+
329
+ # Update selection state for each chat list item
330
+ for child in container.children:
331
+ if isinstance(child, ChatListItem):
332
+ child.is_selected = (child.conversation.id == selected_id)
333
+ except Exception as e:
334
+ # Handle case where container might not be mounted yet
335
+ # This prevents errors during initialization
336
+ pass