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/model_selector.py
ADDED
@@ -0,0 +1,296 @@
|
|
1
|
+
from typing import Dict, List, Any, Optional
|
2
|
+
from textual.app import ComposeResult
|
3
|
+
from textual.containers import Container
|
4
|
+
from textual.widgets import Select, Label, Input
|
5
|
+
from textual.widget import Widget
|
6
|
+
from textual.message import Message
|
7
|
+
|
8
|
+
from ..config import CONFIG
|
9
|
+
from .chat_interface import ChatInterface
|
10
|
+
|
11
|
+
class ModelSelector(Container):
|
12
|
+
"""Widget for selecting the AI model to use"""
|
13
|
+
|
14
|
+
DEFAULT_CSS = """
|
15
|
+
ModelSelector {
|
16
|
+
width: 100%;
|
17
|
+
height: auto;
|
18
|
+
padding: 0;
|
19
|
+
background: $surface-darken-1;
|
20
|
+
}
|
21
|
+
|
22
|
+
#selector-container {
|
23
|
+
width: 100%;
|
24
|
+
layout: horizontal;
|
25
|
+
height: 3;
|
26
|
+
padding: 0;
|
27
|
+
}
|
28
|
+
|
29
|
+
#provider-select {
|
30
|
+
width: 30%;
|
31
|
+
height: 3;
|
32
|
+
margin-right: 1;
|
33
|
+
}
|
34
|
+
|
35
|
+
#model-select, #custom-model-input {
|
36
|
+
width: 1fr;
|
37
|
+
height: 3;
|
38
|
+
}
|
39
|
+
|
40
|
+
#custom-model-input {
|
41
|
+
display: none;
|
42
|
+
}
|
43
|
+
|
44
|
+
#custom-model-input.show {
|
45
|
+
display: block;
|
46
|
+
}
|
47
|
+
|
48
|
+
#model-select.hide {
|
49
|
+
display: none;
|
50
|
+
}
|
51
|
+
"""
|
52
|
+
|
53
|
+
class ModelSelected(Message):
|
54
|
+
"""Event sent when a model is selected"""
|
55
|
+
def __init__(self, model_id: str):
|
56
|
+
self.model_id = model_id
|
57
|
+
super().__init__()
|
58
|
+
|
59
|
+
def __init__(
|
60
|
+
self,
|
61
|
+
selected_model: str = None,
|
62
|
+
name: Optional[str] = None,
|
63
|
+
id: Optional[str] = None
|
64
|
+
):
|
65
|
+
super().__init__(name=name, id=id)
|
66
|
+
self.selected_model = selected_model or CONFIG["default_model"]
|
67
|
+
# Handle custom models not in CONFIG
|
68
|
+
if self.selected_model in CONFIG["available_models"]:
|
69
|
+
self.selected_provider = CONFIG["available_models"][self.selected_model]["provider"]
|
70
|
+
else:
|
71
|
+
# Default to OpenAI for custom models
|
72
|
+
self.selected_provider = "openai"
|
73
|
+
|
74
|
+
def compose(self) -> ComposeResult:
|
75
|
+
"""Set up the model selector"""
|
76
|
+
with Container(id="selector-container"):
|
77
|
+
# Provider options including Ollama
|
78
|
+
provider_options = [
|
79
|
+
("OpenAI", "openai"),
|
80
|
+
("Anthropic", "anthropic"),
|
81
|
+
("Ollama", "ollama")
|
82
|
+
]
|
83
|
+
|
84
|
+
# Provider selector
|
85
|
+
yield Select(
|
86
|
+
provider_options,
|
87
|
+
id="provider-select",
|
88
|
+
value=self.selected_provider,
|
89
|
+
allow_blank=False
|
90
|
+
)
|
91
|
+
|
92
|
+
# Get initial model options synchronously
|
93
|
+
initial_options = []
|
94
|
+
for model_id, model_info in CONFIG["available_models"].items():
|
95
|
+
if model_info["provider"] == self.selected_provider:
|
96
|
+
initial_options.append((model_info["display_name"], model_id))
|
97
|
+
|
98
|
+
# Ensure we have at least the custom option
|
99
|
+
if not initial_options or self.selected_model not in [opt[1] for opt in initial_options]:
|
100
|
+
initial_options.append(("Custom Model...", "custom"))
|
101
|
+
is_custom = True
|
102
|
+
initial_value = "custom"
|
103
|
+
else:
|
104
|
+
is_custom = False
|
105
|
+
initial_value = self.selected_model
|
106
|
+
|
107
|
+
# Model selector and custom input
|
108
|
+
yield Select(
|
109
|
+
initial_options,
|
110
|
+
id="model-select",
|
111
|
+
value=initial_value,
|
112
|
+
classes="hide" if is_custom else "",
|
113
|
+
allow_blank=False
|
114
|
+
)
|
115
|
+
yield Input(
|
116
|
+
value=self.selected_model if is_custom else "",
|
117
|
+
placeholder="Enter custom model name",
|
118
|
+
id="custom-model-input",
|
119
|
+
classes="" if is_custom else "hide"
|
120
|
+
)
|
121
|
+
|
122
|
+
async def on_mount(self) -> None:
|
123
|
+
"""Initialize model options after mount"""
|
124
|
+
# Only update options if using Ollama provider since it needs async API call
|
125
|
+
if self.selected_provider == "ollama":
|
126
|
+
model_select = self.query_one("#model-select", Select)
|
127
|
+
model_options = await self._get_model_options(self.selected_provider)
|
128
|
+
model_select.set_options(model_options)
|
129
|
+
if not self.selected_model or self.selected_model not in CONFIG["available_models"]:
|
130
|
+
model_select.value = "custom"
|
131
|
+
else:
|
132
|
+
model_select.value = self.selected_model
|
133
|
+
|
134
|
+
async def _get_model_options(self, provider: str) -> List[tuple]:
|
135
|
+
"""Get model options for a specific provider"""
|
136
|
+
options = [
|
137
|
+
(model_info["display_name"], model_id)
|
138
|
+
for model_id, model_info in CONFIG["available_models"].items()
|
139
|
+
if model_info["provider"] == provider
|
140
|
+
]
|
141
|
+
|
142
|
+
# Add available Ollama models
|
143
|
+
if provider == "ollama":
|
144
|
+
try:
|
145
|
+
from app.api.ollama import OllamaClient
|
146
|
+
ollama = OllamaClient()
|
147
|
+
ollama_models = await ollama.get_available_models()
|
148
|
+
for model in ollama_models:
|
149
|
+
if model["id"] not in CONFIG["available_models"]:
|
150
|
+
options.append((model["name"], model["id"]))
|
151
|
+
except:
|
152
|
+
pass
|
153
|
+
|
154
|
+
options.append(("Custom Model...", "custom"))
|
155
|
+
return options
|
156
|
+
|
157
|
+
async def on_select_changed(self, event: Select.Changed) -> None:
|
158
|
+
"""Handle select changes"""
|
159
|
+
if event.select.id == "provider-select":
|
160
|
+
self.selected_provider = event.value
|
161
|
+
# Update model options
|
162
|
+
model_select = self.query_one("#model-select", Select)
|
163
|
+
model_options = await self._get_model_options(self.selected_provider)
|
164
|
+
model_select.set_options(model_options)
|
165
|
+
# Select first model of new provider
|
166
|
+
if model_options:
|
167
|
+
self.selected_model = model_options[0][1]
|
168
|
+
model_select.value = self.selected_model
|
169
|
+
self.post_message(self.ModelSelected(self.selected_model))
|
170
|
+
|
171
|
+
elif event.select.id == "model-select":
|
172
|
+
if event.value == "custom":
|
173
|
+
# Show custom input
|
174
|
+
model_select = self.query_one("#model-select")
|
175
|
+
custom_input = self.query_one("#custom-model-input")
|
176
|
+
model_select.add_class("hide")
|
177
|
+
custom_input.remove_class("hide")
|
178
|
+
custom_input.focus()
|
179
|
+
else:
|
180
|
+
# Hide custom input
|
181
|
+
model_select = self.query_one("#model-select")
|
182
|
+
custom_input = self.query_one("#custom-model-input")
|
183
|
+
model_select.remove_class("hide")
|
184
|
+
custom_input.add_class("hide")
|
185
|
+
self.selected_model = event.value
|
186
|
+
self.post_message(self.ModelSelected(event.value))
|
187
|
+
|
188
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
189
|
+
"""Handle custom model input changes"""
|
190
|
+
if event.input.id == "custom-model-input":
|
191
|
+
value = event.value.strip()
|
192
|
+
if value: # Only update if there's actual content
|
193
|
+
self.selected_model = value
|
194
|
+
self.post_message(self.ModelSelected(value))
|
195
|
+
|
196
|
+
|
197
|
+
def get_selected_model(self) -> str:
|
198
|
+
"""Get the current selected model ID"""
|
199
|
+
return self.selected_model
|
200
|
+
|
201
|
+
def set_selected_model(self, model_id: str) -> None:
|
202
|
+
"""Set the selected model"""
|
203
|
+
self.selected_model = model_id
|
204
|
+
if model_id in CONFIG["available_models"]:
|
205
|
+
select = self.query_one("#model-select", Select)
|
206
|
+
custom_input = self.query_one("#custom-model-input")
|
207
|
+
select.value = model_id
|
208
|
+
select.remove_class("hide")
|
209
|
+
custom_input.add_class("hide")
|
210
|
+
else:
|
211
|
+
select = self.query_one("#model-select", Select)
|
212
|
+
custom_input = self.query_one("#custom-model-input")
|
213
|
+
select.value = "custom"
|
214
|
+
select.add_class("hide")
|
215
|
+
custom_input.value = model_id
|
216
|
+
custom_input.remove_class("hide")
|
217
|
+
|
218
|
+
class StyleSelector(Container):
|
219
|
+
"""Widget for selecting the AI response style"""
|
220
|
+
|
221
|
+
DEFAULT_CSS = """
|
222
|
+
StyleSelector {
|
223
|
+
width: 100%;
|
224
|
+
height: auto;
|
225
|
+
padding: 0;
|
226
|
+
background: $surface-darken-1;
|
227
|
+
}
|
228
|
+
|
229
|
+
#selector-container {
|
230
|
+
width: 100%;
|
231
|
+
layout: horizontal;
|
232
|
+
height: 3;
|
233
|
+
padding: 0;
|
234
|
+
}
|
235
|
+
|
236
|
+
#style-label {
|
237
|
+
width: 30%;
|
238
|
+
height: 3;
|
239
|
+
content-align: left middle;
|
240
|
+
padding-right: 1;
|
241
|
+
}
|
242
|
+
|
243
|
+
#style-select {
|
244
|
+
width: 1fr;
|
245
|
+
height: 3;
|
246
|
+
}
|
247
|
+
"""
|
248
|
+
|
249
|
+
class StyleSelected(Message):
|
250
|
+
"""Event sent when a style is selected"""
|
251
|
+
def __init__(self, style_id: str):
|
252
|
+
self.style_id = style_id
|
253
|
+
super().__init__()
|
254
|
+
|
255
|
+
def __init__(
|
256
|
+
self,
|
257
|
+
selected_style: str = None,
|
258
|
+
name: Optional[str] = None,
|
259
|
+
id: Optional[str] = None
|
260
|
+
):
|
261
|
+
super().__init__(name=name, id=id)
|
262
|
+
self.selected_style = selected_style or CONFIG["default_style"]
|
263
|
+
|
264
|
+
def compose(self) -> ComposeResult:
|
265
|
+
"""Set up the style selector"""
|
266
|
+
with Container(id="selector-container"):
|
267
|
+
yield Label("Style:", id="style-label")
|
268
|
+
|
269
|
+
# Get style options
|
270
|
+
options = []
|
271
|
+
for style_id, style_info in CONFIG["user_styles"].items():
|
272
|
+
options.append((style_info["name"], style_id))
|
273
|
+
|
274
|
+
yield Select(
|
275
|
+
options,
|
276
|
+
id="style-select",
|
277
|
+
value=self.selected_style,
|
278
|
+
allow_blank=False
|
279
|
+
)
|
280
|
+
|
281
|
+
def on_select_changed(self, event: Select.Changed) -> None:
|
282
|
+
"""Handle select changes"""
|
283
|
+
if event.select.id == "style-select":
|
284
|
+
self.selected_style = event.value
|
285
|
+
self.post_message(self.StyleSelected(event.value))
|
286
|
+
|
287
|
+
def get_selected_style(self) -> str:
|
288
|
+
"""Get the current selected style ID"""
|
289
|
+
return self.selected_style
|
290
|
+
|
291
|
+
def set_selected_style(self, style_id: str) -> None:
|
292
|
+
"""Set the selected style"""
|
293
|
+
if style_id in CONFIG["user_styles"]:
|
294
|
+
self.selected_style = style_id
|
295
|
+
select = self.query_one("#style-select", Select)
|
296
|
+
select.value = style_id
|
app/ui/search.py
ADDED
@@ -0,0 +1,308 @@
|
|
1
|
+
from typing import List, Dict, Any, Optional, Callable
|
2
|
+
import time
|
3
|
+
|
4
|
+
from textual.app import ComposeResult
|
5
|
+
from textual.containers import Container, ScrollableContainer
|
6
|
+
from textual.reactive import reactive
|
7
|
+
from textual.widgets import Button, Input, Label, Static
|
8
|
+
from textual.widget import Widget
|
9
|
+
from textual.message import Message
|
10
|
+
from textual.timer import Timer
|
11
|
+
|
12
|
+
from ..models import Conversation
|
13
|
+
from ..database import ChatDatabase
|
14
|
+
from ..config import CONFIG
|
15
|
+
|
16
|
+
class SearchResult(Static):
|
17
|
+
"""Widget to display a single search result"""
|
18
|
+
|
19
|
+
DEFAULT_CSS = """
|
20
|
+
SearchResult {
|
21
|
+
width: 100%;
|
22
|
+
height: auto;
|
23
|
+
min-height: 3;
|
24
|
+
padding: 1;
|
25
|
+
border-bottom: solid $primary-darken-3;
|
26
|
+
}
|
27
|
+
|
28
|
+
SearchResult:hover {
|
29
|
+
background: $primary-darken-2;
|
30
|
+
}
|
31
|
+
|
32
|
+
.result-title {
|
33
|
+
width: 100%;
|
34
|
+
content-align: center middle;
|
35
|
+
text-align: left;
|
36
|
+
text-style: bold;
|
37
|
+
}
|
38
|
+
|
39
|
+
.result-preview {
|
40
|
+
width: 100%;
|
41
|
+
color: $text-muted;
|
42
|
+
margin-top: 1;
|
43
|
+
text-align: left;
|
44
|
+
}
|
45
|
+
|
46
|
+
.result-date {
|
47
|
+
width: 100%;
|
48
|
+
color: $text-muted;
|
49
|
+
text-align: right;
|
50
|
+
text-style: italic;
|
51
|
+
}
|
52
|
+
"""
|
53
|
+
|
54
|
+
class ResultSelected(Message):
|
55
|
+
"""Event sent when a search result is selected"""
|
56
|
+
def __init__(self, conversation_id: int):
|
57
|
+
self.conversation_id = conversation_id
|
58
|
+
super().__init__()
|
59
|
+
|
60
|
+
def __init__(
|
61
|
+
self,
|
62
|
+
conversation: Conversation,
|
63
|
+
name: Optional[str] = None
|
64
|
+
):
|
65
|
+
super().__init__(name=name)
|
66
|
+
self.conversation = conversation
|
67
|
+
|
68
|
+
def compose(self) -> ComposeResult:
|
69
|
+
"""Set up the search result"""
|
70
|
+
yield Label(self.conversation.title, classes="result-title")
|
71
|
+
|
72
|
+
# Preview text (truncate if too long)
|
73
|
+
preview = getattr(self.conversation, 'preview', '')
|
74
|
+
if preview and len(preview) > 100:
|
75
|
+
preview = preview[:100] + "..."
|
76
|
+
|
77
|
+
yield Label(preview, classes="result-preview")
|
78
|
+
|
79
|
+
# Format date
|
80
|
+
updated_at = self.conversation.updated_at
|
81
|
+
if updated_at:
|
82
|
+
try:
|
83
|
+
from datetime import datetime
|
84
|
+
dt = datetime.fromisoformat(updated_at)
|
85
|
+
formatted_date = dt.strftime("%Y-%m-%d %H:%M")
|
86
|
+
except:
|
87
|
+
formatted_date = updated_at
|
88
|
+
else:
|
89
|
+
formatted_date = "Unknown"
|
90
|
+
|
91
|
+
yield Label(formatted_date, classes="result-date")
|
92
|
+
|
93
|
+
def on_click(self) -> None:
|
94
|
+
"""Handle click events"""
|
95
|
+
self.post_message(self.ResultSelected(self.conversation.id))
|
96
|
+
|
97
|
+
class SearchBar(Container):
|
98
|
+
"""Widget for searching conversations"""
|
99
|
+
|
100
|
+
DEFAULT_CSS = """
|
101
|
+
SearchBar {
|
102
|
+
width: 100%;
|
103
|
+
height: auto;
|
104
|
+
padding: 1;
|
105
|
+
background: $surface-darken-1;
|
106
|
+
}
|
107
|
+
|
108
|
+
#search-input {
|
109
|
+
width: 100%;
|
110
|
+
height: 3;
|
111
|
+
margin-bottom: 1;
|
112
|
+
}
|
113
|
+
|
114
|
+
#search-results-container {
|
115
|
+
width: 100%;
|
116
|
+
height: auto;
|
117
|
+
max-height: 15;
|
118
|
+
background: $surface;
|
119
|
+
display: none;
|
120
|
+
}
|
121
|
+
|
122
|
+
#search-results-count {
|
123
|
+
width: 100%;
|
124
|
+
height: 2;
|
125
|
+
background: $primary-darken-1;
|
126
|
+
color: $text;
|
127
|
+
content-align: center middle;
|
128
|
+
text-align: center;
|
129
|
+
}
|
130
|
+
|
131
|
+
#loading-indicator {
|
132
|
+
width: 100%;
|
133
|
+
height: 1;
|
134
|
+
background: $primary-darken-1;
|
135
|
+
color: $text;
|
136
|
+
display: none;
|
137
|
+
}
|
138
|
+
|
139
|
+
#no-results {
|
140
|
+
width: 100%;
|
141
|
+
height: 3;
|
142
|
+
color: $text-muted;
|
143
|
+
content-align: center middle;
|
144
|
+
text-align: center;
|
145
|
+
display: none;
|
146
|
+
}
|
147
|
+
"""
|
148
|
+
|
149
|
+
is_searching = reactive(False)
|
150
|
+
search_results = reactive([])
|
151
|
+
search_timer: Optional[Timer] = None
|
152
|
+
|
153
|
+
class SearchResultSelected(Message):
|
154
|
+
"""Event sent when a search result is selected"""
|
155
|
+
def __init__(self, conversation_id: int):
|
156
|
+
self.conversation_id = conversation_id
|
157
|
+
super().__init__()
|
158
|
+
|
159
|
+
def __init__(
|
160
|
+
self,
|
161
|
+
db: ChatDatabase,
|
162
|
+
name: Optional[str] = None,
|
163
|
+
id: Optional[str] = None
|
164
|
+
):
|
165
|
+
super().__init__(name=name, id=id)
|
166
|
+
self.db = db
|
167
|
+
|
168
|
+
def compose(self) -> ComposeResult:
|
169
|
+
"""Set up the search bar"""
|
170
|
+
yield Input(placeholder="Search conversations...", id="search-input")
|
171
|
+
|
172
|
+
with Container(id="loading-indicator"):
|
173
|
+
yield Label("Searching...", id="loading-text")
|
174
|
+
|
175
|
+
with Container(id="search-results-container"):
|
176
|
+
yield Label("Search Results", id="search-results-count")
|
177
|
+
|
178
|
+
with ScrollableContainer(id="results-scroll"):
|
179
|
+
yield Label("No results found.", id="no-results")
|
180
|
+
|
181
|
+
def on_unmount(self) -> None:
|
182
|
+
"""Clean up when unmounting"""
|
183
|
+
if self.search_timer:
|
184
|
+
self.search_timer.stop()
|
185
|
+
self.search_timer = None
|
186
|
+
|
187
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
188
|
+
"""Handle input changes"""
|
189
|
+
if event.input.id == "search-input":
|
190
|
+
query = event.value.strip()
|
191
|
+
|
192
|
+
# Cancel existing timer if any
|
193
|
+
if self.search_timer:
|
194
|
+
self.search_timer.stop()
|
195
|
+
self.search_timer = None
|
196
|
+
|
197
|
+
if not query:
|
198
|
+
# Hide results when search is cleared
|
199
|
+
self.clear_results()
|
200
|
+
try:
|
201
|
+
results_container = self.query_one("#search-results-container")
|
202
|
+
if results_container:
|
203
|
+
results_container.display = False
|
204
|
+
except Exception:
|
205
|
+
pass
|
206
|
+
# Return focus to chat input
|
207
|
+
self._return_focus_to_chat()
|
208
|
+
return
|
209
|
+
|
210
|
+
# Start a new timer (debounce)
|
211
|
+
try:
|
212
|
+
self.search_timer = self.set_timer(0.3, self.perform_search, query)
|
213
|
+
except Exception:
|
214
|
+
# If timer creation fails, perform search immediately
|
215
|
+
self.perform_search(query)
|
216
|
+
|
217
|
+
def _return_focus_to_chat(self) -> None:
|
218
|
+
"""Helper to return focus to chat input"""
|
219
|
+
try:
|
220
|
+
from .chat_interface import ChatInterface
|
221
|
+
chat_interface = self.app.query_one("#chat-interface", expect_type=ChatInterface)
|
222
|
+
if chat_interface:
|
223
|
+
input_field = chat_interface.query_one("#message-input")
|
224
|
+
if input_field:
|
225
|
+
self.app.set_focus(input_field)
|
226
|
+
except Exception:
|
227
|
+
pass
|
228
|
+
|
229
|
+
def perform_search(self, query: str) -> None:
|
230
|
+
"""Perform the search after debounce"""
|
231
|
+
if not self.is_mounted:
|
232
|
+
return
|
233
|
+
|
234
|
+
self.is_searching = True
|
235
|
+
|
236
|
+
try:
|
237
|
+
search_results = self.db.search_conversations(query)
|
238
|
+
self.search_results = [Conversation.from_dict(c) for c in search_results]
|
239
|
+
|
240
|
+
if self.is_mounted: # Check if still mounted before updating UI
|
241
|
+
try:
|
242
|
+
results_container = self.query_one("#search-results-container")
|
243
|
+
if results_container:
|
244
|
+
results_container.display = True
|
245
|
+
self._update_results_ui()
|
246
|
+
except Exception:
|
247
|
+
pass
|
248
|
+
except Exception:
|
249
|
+
# Handle search errors gracefully
|
250
|
+
self.search_results = []
|
251
|
+
if self.is_mounted:
|
252
|
+
try:
|
253
|
+
self._update_results_ui()
|
254
|
+
except Exception:
|
255
|
+
pass
|
256
|
+
finally:
|
257
|
+
self.is_searching = False
|
258
|
+
if self.search_timer:
|
259
|
+
self.search_timer.stop()
|
260
|
+
self.search_timer = None
|
261
|
+
|
262
|
+
def _update_results_ui(self) -> None:
|
263
|
+
"""Update the UI with current search results"""
|
264
|
+
results_count = self.query_one("#search-results-count")
|
265
|
+
results_count.update(f"Found {len(self.search_results)} results")
|
266
|
+
|
267
|
+
scroll_container = self.query_one("#results-scroll")
|
268
|
+
|
269
|
+
# Clear previous results
|
270
|
+
for child in scroll_container.children:
|
271
|
+
if not child.id == "no-results":
|
272
|
+
child.remove()
|
273
|
+
|
274
|
+
no_results = self.query_one("#no-results")
|
275
|
+
|
276
|
+
if not self.search_results:
|
277
|
+
no_results.display = True
|
278
|
+
return
|
279
|
+
else:
|
280
|
+
no_results.display = False
|
281
|
+
|
282
|
+
# Mount results directly without using batch_update
|
283
|
+
for result in self.search_results:
|
284
|
+
scroll_container.mount(SearchResult(result))
|
285
|
+
|
286
|
+
def on_search_result_result_selected(self, event: SearchResult.ResultSelected) -> None:
|
287
|
+
"""Handle search result selection"""
|
288
|
+
self.post_message(self.SearchResultSelected(event.conversation_id))
|
289
|
+
|
290
|
+
# Clear search and hide results
|
291
|
+
input_widget = self.query_one("#search-input", Input)
|
292
|
+
input_widget.value = ""
|
293
|
+
|
294
|
+
results_container = self.query_one("#search-results-container")
|
295
|
+
results_container.display = False
|
296
|
+
|
297
|
+
# Return focus to chat input
|
298
|
+
self._return_focus_to_chat()
|
299
|
+
|
300
|
+
def clear_results(self) -> None:
|
301
|
+
"""Clear search results"""
|
302
|
+
self.search_results = []
|
303
|
+
self._update_results_ui()
|
304
|
+
|
305
|
+
def watch_is_searching(self, is_searching: bool) -> None:
|
306
|
+
"""Watch the is_searching property"""
|
307
|
+
loading = self.query_one("#loading-indicator")
|
308
|
+
loading.display = True if is_searching else False
|