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.
- app/__init__.py +6 -0
- app/api/__init__.py +1 -0
- app/api/anthropic.py +92 -0
- app/api/base.py +74 -0
- app/api/ollama.py +116 -0
- app/api/openai.py +78 -0
- app/config.py +127 -0
- app/database.py +285 -0
- app/main.py +599 -0
- app/models.py +83 -0
- app/ui/__init__.py +1 -0
- app/ui/chat_interface.py +345 -0
- app/ui/chat_list.py +336 -0
- app/ui/model_selector.py +296 -0
- app/ui/search.py +308 -0
- app/ui/styles.py +275 -0
- app/utils.py +202 -0
- chat_console-0.1.1.dist-info/LICENSE +21 -0
- chat_console-0.1.1.dist-info/METADATA +111 -0
- chat_console-0.1.1.dist-info/RECORD +23 -0
- chat_console-0.1.1.dist-info/WHEEL +5 -0
- chat_console-0.1.1.dist-info/entry_points.txt +3 -0
- chat_console-0.1.1.dist-info/top_level.txt +1 -0
app/ui/chat_interface.py
ADDED
@@ -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
|