parllama 0.2.0__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.
Files changed (55) hide show
  1. parllama/__init__.py +22 -0
  2. parllama/__main__.py +25 -0
  3. parllama/app.py +615 -0
  4. parllama/app.tcss +152 -0
  5. parllama/data_manager.py +246 -0
  6. parllama/dialogs/__init__.py +3 -0
  7. parllama/dialogs/error_dialog.py +26 -0
  8. parllama/dialogs/help_dialog.py +79 -0
  9. parllama/dialogs/information.py +13 -0
  10. parllama/dialogs/input_dialog.py +108 -0
  11. parllama/dialogs/model_details_dialog.py +132 -0
  12. parllama/dialogs/password_dialog.py +88 -0
  13. parllama/dialogs/text_dialog.py +75 -0
  14. parllama/dialogs/yes_no_dialog.py +111 -0
  15. parllama/docker_utils.py +116 -0
  16. parllama/help.md +95 -0
  17. parllama/icons.py +25 -0
  18. parllama/messages/__init__.py +3 -0
  19. parllama/messages/main.py +144 -0
  20. parllama/models/__init__.py +3 -0
  21. parllama/models/jobs.py +30 -0
  22. parllama/models/ollama_data.py +75 -0
  23. parllama/models/settings_data.py +100 -0
  24. parllama/par_logger.py +49 -0
  25. parllama/py.typed +0 -0
  26. parllama/screens/__init__.py +3 -0
  27. parllama/screens/local_models_screen.py +263 -0
  28. parllama/screens/local_models_screen.tcss +12 -0
  29. parllama/screens/log_screen.py +50 -0
  30. parllama/screens/log_screen.tcss +9 -0
  31. parllama/screens/model_tools_screen.py +57 -0
  32. parllama/screens/model_tools_screen.tcss +11 -0
  33. parllama/screens/site_models_screen.py +207 -0
  34. parllama/screens/site_models_screen.tcss +46 -0
  35. parllama/theme_manager.py +122 -0
  36. parllama/themes/__init__.py +3 -0
  37. parllama/themes/par.json +25 -0
  38. parllama/time_display.py +64 -0
  39. parllama/utils.py +592 -0
  40. parllama/widgets/__init__.py +3 -0
  41. parllama/widgets/clickable_label.py +67 -0
  42. parllama/widgets/dbl_click_list_item.py +29 -0
  43. parllama/widgets/field_set.py +90 -0
  44. parllama/widgets/filter_input.py +22 -0
  45. parllama/widgets/grid_list.py +284 -0
  46. parllama/widgets/hidden_input.py +59 -0
  47. parllama/widgets/input_tab_complete.py +44 -0
  48. parllama/widgets/local_model_list_item.py +68 -0
  49. parllama/widgets/site_model_list_item.py +73 -0
  50. parllama/widgets/site_model_list_view.py +20 -0
  51. parllama-0.2.0.dist-info/METADATA +194 -0
  52. parllama-0.2.0.dist-info/RECORD +55 -0
  53. parllama-0.2.0.dist-info/WHEEL +5 -0
  54. parllama-0.2.0.dist-info/entry_points.txt +2 -0
  55. parllama-0.2.0.dist-info/top_level.txt +1 -0
parllama/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """PAR LLAMA TUI - A terminal user interface for Ollama."""
2
+
3
+ __author__ = "Paul Robello"
4
+ __copyright__ = "Copyright 2024, Paul Robello"
5
+ __credits__ = ["Paul Robello"]
6
+ __maintainer__ = "Paul Robello"
7
+ __email__ = "probello@gmail.com"
8
+ __version__ = "0.2.0"
9
+ __licence__ = "MIT"
10
+ __application_title__ = "PAR LLAMA"
11
+
12
+
13
+ __all__: list[str] = [
14
+ "__author__",
15
+ "__copyright__",
16
+ "__credits__",
17
+ "__maintainer__",
18
+ "__email__",
19
+ "__version__",
20
+ "__licence__",
21
+ "__application_title__",
22
+ ]
parllama/__main__.py ADDED
@@ -0,0 +1,25 @@
1
+ """The main entry point for the application."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from parllama.app import ParLlamaApp
8
+ from parllama.models.settings_data import settings
9
+
10
+ if os.environ.get("DEBUG"):
11
+ import pydevd_pycharm # type: ignore
12
+
13
+ pydevd_pycharm.settrace(
14
+ "localhost", port=12345, suspend=False, patch_multiprocessing=True
15
+ )
16
+
17
+
18
+ def run() -> None:
19
+ """Run the application."""
20
+ print(f"Settings folder {settings.data_dir}")
21
+ ParLlamaApp().run()
22
+
23
+
24
+ if __name__ == "__main__":
25
+ run()
parllama/app.py ADDED
@@ -0,0 +1,615 @@
1
+ """The main application class."""
2
+
3
+ import asyncio
4
+ import inspect
5
+ from queue import Empty, Queue
6
+ from typing import Any, Dict, Iterator, List
7
+
8
+ import ollama
9
+ import pyperclip # type: ignore
10
+ from rich.columns import Columns
11
+ from rich.console import ConsoleRenderable, RenderableType, RichCast
12
+ from rich.progress_bar import ProgressBar
13
+ from rich.segment import Segment
14
+ from rich.style import Style
15
+ from rich.text import Text
16
+ from textual import LogGroup, LogVerbosity, on, work
17
+ from textual.app import App
18
+ from textual.binding import Binding
19
+ from textual.color import Color
20
+ from textual.message import Message
21
+ from textual.strip import Strip
22
+ from textual.widget import Widget
23
+ from textual.widgets import Input, Select, TextArea
24
+
25
+ from parllama import __application_title__
26
+ from parllama.data_manager import dm
27
+ from parllama.dialogs.help_dialog import HelpDialog
28
+ from parllama.messages.main import (
29
+ LocalModelCopied,
30
+ LocalModelCopyRequested,
31
+ LocalModelDelete,
32
+ LocalModelDeleted,
33
+ LocalModelListLoaded,
34
+ LocalModelListRefreshRequested,
35
+ ModelPulled,
36
+ ModelPullRequested,
37
+ ModelPushed,
38
+ ModelPushRequested,
39
+ NotifyErrorMessage,
40
+ NotifyInfoMessage,
41
+ PsMessage,
42
+ SendToClipboard,
43
+ SiteModelsLoaded,
44
+ SiteModelsRefreshRequested,
45
+ StatusMessage,
46
+ )
47
+ from parllama.models.jobs import CopyModelJob, PullModelJob, PushModelJob, QueueJob
48
+ from parllama.models.settings_data import settings
49
+ from parllama.par_logger import ParLogger
50
+ from parllama.screens.local_models_screen import LocalModelsScreen
51
+ from parllama.screens.log_screen import LogScreen
52
+ from parllama.screens.model_tools_screen import ModelToolsScreen
53
+ from parllama.screens.site_models_screen import SiteModelsScreen
54
+ from parllama.theme_manager import theme_manager
55
+
56
+
57
+ class ParLlamaApp(App[None]):
58
+ """Main application class"""
59
+
60
+ TITLE = __application_title__
61
+ BINDINGS = [
62
+ Binding(key="f1", action="help", description="Help", show=True, priority=True),
63
+ Binding(key="ctrl+q", action="app.quit", description="Quit", show=True),
64
+ Binding(
65
+ key="ctrl+l",
66
+ action="app.switch_mode('local')",
67
+ description="Local",
68
+ show=True,
69
+ priority=True,
70
+ ),
71
+ Binding(
72
+ key="ctrl+s",
73
+ action="app.switch_mode('site')",
74
+ description="Site",
75
+ show=True,
76
+ priority=True,
77
+ ),
78
+ Binding(
79
+ key="ctrl+t",
80
+ action="app.switch_mode('tools')",
81
+ description="Tools",
82
+ show=True,
83
+ priority=True,
84
+ ),
85
+ Binding(
86
+ key="ctrl+d",
87
+ action="app.switch_mode('logs')",
88
+ description="Debug",
89
+ show=True,
90
+ priority=True,
91
+ ),
92
+ Binding(
93
+ key="f10",
94
+ action="toggle_dark",
95
+ description="Toggle Dark Mode",
96
+ show=True,
97
+ priority=True,
98
+ ),
99
+ Binding(key="ctrl+c", action="noop", show=False),
100
+ ]
101
+ SCREENS = {}
102
+
103
+ MODES = {"local": "local", "site": "site", "tools": "tools", "logs": "logs"}
104
+
105
+ commands: list[dict[str, str]] = [
106
+ {
107
+ "action": "action_quit",
108
+ "cmd": "Quit Application",
109
+ "help": "Quit the application as soon as possible",
110
+ }
111
+ ]
112
+ CSS_PATH = "app.tcss"
113
+
114
+ DEFAULT_CSS = """
115
+ """
116
+
117
+ local_models_screen: LocalModelsScreen
118
+ site_models_screen: SiteModelsScreen
119
+ model_tools_screen: ModelToolsScreen
120
+ log_screen: LogScreen | None
121
+ job_queue: Queue[QueueJob]
122
+ is_busy: bool = False
123
+ last_status: RenderableType = ""
124
+
125
+ def __init__(self) -> None:
126
+ """Initialize the application."""
127
+ super().__init__()
128
+ self._logger = ParLogger(self._log)
129
+
130
+ self.title = __application_title__
131
+ self.dark = settings.theme_mode != "light"
132
+ self.design = theme_manager.get_theme(settings.theme_name) # type: ignore
133
+ self.job_queue = Queue[QueueJob]()
134
+ self.is_busy = False
135
+ self.is_refreshing = False
136
+ self.last_status = ""
137
+ self.log_screen = None
138
+
139
+ def _watch_dark(self, value: bool) -> None:
140
+ """Watch the dark property."""
141
+ settings.theme_mode = "dark" if value else "light"
142
+ settings.save_settings_to_file()
143
+
144
+ def get_css_variables(self) -> dict[str, str]:
145
+ """Get a mapping of variables used to pre-populate CSS.
146
+
147
+ May be implemented in a subclass to add new CSS variables.
148
+
149
+ Returns:
150
+ A mapping of variable name to value.
151
+ """
152
+ return theme_manager.get_color_system_for_theme_mode(
153
+ settings.theme_name, self.dark
154
+ ).generate()
155
+
156
+ @on(NotifyInfoMessage)
157
+ def notify_info(self, msg: NotifyInfoMessage) -> None:
158
+ """Show info toast message for 3 seconds"""
159
+ self.notify(msg.message, timeout=3)
160
+
161
+ @on(NotifyErrorMessage)
162
+ def notify_error(self, msg: NotifyErrorMessage) -> None:
163
+ """Show error toast message for 6 seconds"""
164
+ self.notify(msg.message, severity="error", timeout=6)
165
+
166
+ async def on_mount(self) -> None:
167
+ """Display the main or locked screen."""
168
+ self.SCREENS["local"] = LocalModelsScreen()
169
+ self.SCREENS["site"] = SiteModelsScreen()
170
+ self.SCREENS["tools"] = ModelToolsScreen()
171
+ self.SCREENS["logs"] = LogScreen()
172
+
173
+ self._installed_screens.update(**self.SCREENS)
174
+
175
+ self.local_models_screen = self.SCREENS["local"] # type: ignore
176
+ self.site_models_screen = self.SCREENS["site"] # type: ignore
177
+ self.model_tools_screen = self.SCREENS["tools"] # type: ignore
178
+ self.log_screen = self.SCREENS["logs"] # type: ignore
179
+
180
+ await self.switch_mode("local")
181
+
182
+ self.set_timer(1, self.do_jobs)
183
+ self.set_timer(1, self.update_ps)
184
+
185
+ def action_noop(self) -> None:
186
+ """Do nothing"""
187
+
188
+ def action_help(self) -> None:
189
+ """Show help screen"""
190
+ self.app.push_screen(HelpDialog())
191
+
192
+ def action_clear_field(self) -> None:
193
+ """Clear focused widget value"""
194
+ f: Widget | None = self.screen.focused
195
+ if not f:
196
+ return
197
+ if isinstance(f, Input):
198
+ f.value = ""
199
+
200
+ if isinstance(f, TextArea):
201
+ f.text = ""
202
+
203
+ def action_copy_to_clipboard(self) -> None:
204
+ """Copy focused widget value to clipboard"""
205
+ f: Widget | None = self.screen.focused
206
+ if not f:
207
+ return
208
+
209
+ if isinstance(f, (Input, Select)):
210
+ self.app.post_message(SendToClipboard(str(f.value) if f.value else ""))
211
+
212
+ if isinstance(f, TextArea):
213
+ self.app.post_message(SendToClipboard(f.selected_text or f.text))
214
+
215
+ def action_cut_to_clipboard(self) -> None:
216
+ """Cut focused widget value to clipboard"""
217
+ f: Widget | None = self.screen.focused
218
+ if not f:
219
+ return
220
+ if isinstance(f, Input):
221
+ pyperclip.copy(f.value)
222
+ f.value = ""
223
+ if isinstance(f, TextArea):
224
+ pyperclip.copy(f.selected_text or f.text)
225
+ f.text = ""
226
+
227
+ @on(SendToClipboard)
228
+ def send_to_clipboard(self, msg: SendToClipboard) -> None:
229
+ """Send string to clipboard"""
230
+ # works for remote ssh sessions
231
+ self.copy_to_clipboard(msg.message)
232
+ # works for local sessions
233
+ pyperclip.copy(msg.message)
234
+ if msg.notify:
235
+ self.post_message(NotifyInfoMessage("Value copied to clipboard"))
236
+
237
+ @on(ModelPushRequested)
238
+ def on_model_push_requested(self, msg: ModelPushRequested) -> None:
239
+ """Push requested model event"""
240
+ self.job_queue.put(PushModelJob(modelName=msg.model_name))
241
+ if self.local_models_screen:
242
+ self.local_models_screen.grid.set_item_loading(msg.model_name, True)
243
+ # self.notify(f"Model push {msg.model_name} requested")
244
+
245
+ # primary_style = Style(
246
+ # color=theme_manager.get_color_system_for_theme_mode(
247
+ # self.theme, self.dark
248
+ # ).primary.rich_color
249
+ # )
250
+ # background_style = Style(
251
+ # color=(
252
+ # theme_manager.get_color_system_for_theme_mode(
253
+ # self.theme, self.dark
254
+ # ).surface
255
+
256
+ @on(LocalModelDelete)
257
+ def on_local_model_delete(self, msg: LocalModelDelete) -> None:
258
+ """Delete model event"""
259
+ if not dm.delete_model(msg.model_name):
260
+ if self.local_models_screen:
261
+ self.local_models_screen.grid.set_item_loading(msg.model_name, False)
262
+ self.notify(f"Error deleting model {msg.model_name}.", severity="error")
263
+ return
264
+ if self.local_models_screen:
265
+ self.local_models_screen.post_message(LocalModelDeleted(msg.model_name))
266
+
267
+ @on(LocalModelDeleted)
268
+ def on_model_deleted(self, msg: LocalModelDeleted) -> None:
269
+ """Model deleted event"""
270
+ self.notify(f"Model {msg.model_name} deleted.")
271
+
272
+ @on(ModelPullRequested)
273
+ def on_model_pull_requested(self, msg: ModelPullRequested) -> None:
274
+ """Pull requested model event"""
275
+ self.job_queue.put(PullModelJob(modelName=msg.model_name))
276
+ if self.local_models_screen:
277
+ self.local_models_screen.grid.set_item_loading(msg.model_name, True)
278
+ # self.notify(f"Model pull {msg.model_name} requested")
279
+
280
+ # primary_style = Style(
281
+ # color=theme_manager.get_color_system_for_theme_mode(
282
+ # self.theme, self.dark
283
+ # ).primary.rich_color
284
+ # )
285
+ # background_style = Style(
286
+ # color=(
287
+ # theme_manager.get_color_system_for_theme_mode(
288
+ # self.theme, self.dark
289
+ # ).surface
290
+ # or Color.parse("#111")
291
+ # ).rich_color
292
+ # )
293
+ # self.screen.post_message(
294
+ # StatusMessage(
295
+ # Columns(
296
+ # [
297
+ # Text.assemble("status", " "),
298
+ # ProgressBar(
299
+ # total=100,
300
+ # completed=50,
301
+ # width=40,
302
+ # style=background_style,
303
+ # complete_style=primary_style,
304
+ # finished_style=primary_style,
305
+ # ),
306
+ # ]
307
+ # )
308
+ # )
309
+ # )
310
+
311
+ @on(LocalModelCopyRequested)
312
+ def on_local_model_copy_requested(self, msg: LocalModelCopyRequested) -> None:
313
+ """Local model copy request event"""
314
+ self.job_queue.put(
315
+ CopyModelJob(modelName=msg.src_model_name, dstModelName=msg.dst_model_name)
316
+ )
317
+
318
+ async def do_copy_local_model(self, msg: CopyModelJob) -> None:
319
+ """Copy local model"""
320
+ ret = dm.copy_model(msg.modelName, msg.dstModelName)
321
+ self.local_models_screen.post_message(
322
+ LocalModelCopied(
323
+ src_model_name=msg.modelName,
324
+ dst_model_name=msg.dstModelName,
325
+ success=ret["status"] == "success",
326
+ )
327
+ )
328
+
329
+ @on(LocalModelCopied)
330
+ def on_local_model_copied(self, msg: LocalModelCopied) -> None:
331
+ """Local model copied event"""
332
+ if msg.success:
333
+ self.notify(f"Model {msg.src_model_name} copied to {msg.dst_model_name}")
334
+ else:
335
+ self.notify(
336
+ f"Copying model {msg.src_model_name} to {msg.dst_model_name} failed",
337
+ severity="error",
338
+ )
339
+
340
+ async def do_progress(self, job: QueueJob, res: Iterator[Dict[str, Any]]) -> str:
341
+ """Update progress bar"""
342
+ try:
343
+ last_status = ""
344
+ for msg in res:
345
+ last_status = msg["status"]
346
+ pb: ProgressBar | None = None
347
+ if "total" in msg and "completed" in msg:
348
+ msg["percent"] = (
349
+ str(int(msg["completed"] / msg["total"] * 100)) + "%"
350
+ )
351
+ primary_style = Style(
352
+ color=theme_manager.get_color_system_for_theme_mode(
353
+ settings.theme_name, self.dark
354
+ ).primary.rich_color
355
+ )
356
+ background_style = Style(
357
+ color=(
358
+ theme_manager.get_color_system_for_theme_mode(
359
+ settings.theme_name, self.dark
360
+ ).surface
361
+ or Color.parse("#111")
362
+ ).rich_color
363
+ )
364
+ pb = ProgressBar(
365
+ total=msg["total"],
366
+ completed=msg["completed"],
367
+ width=25,
368
+ style=background_style,
369
+ complete_style=primary_style,
370
+ finished_style=primary_style,
371
+ )
372
+ else:
373
+ msg["percent"] = ""
374
+ if msg["percent"] and msg["status"] == "success":
375
+ msg["percent"] = "100%"
376
+ parts: List[RenderableType] = [
377
+ Text.assemble(
378
+ job.modelName,
379
+ " ",
380
+ msg["status"],
381
+ " ",
382
+ msg["percent"],
383
+ " ",
384
+ )
385
+ ]
386
+ if pb:
387
+ parts.append(pb)
388
+
389
+ self.post_message_all(StatusMessage(Columns(parts)))
390
+ return last_status
391
+ except ollama.ResponseError as e:
392
+ self.post_message_all(
393
+ StatusMessage(Text.assemble(("error:" + str(e), "red")))
394
+ )
395
+ raise e
396
+
397
+ async def do_pull(self, job: PullModelJob) -> None:
398
+ """Pull a model"""
399
+ try:
400
+ res = dm.pull_model(job.modelName)
401
+ last_status = await self.do_progress(job, res)
402
+
403
+ self.local_models_screen.post_message(
404
+ ModelPulled(model_name=job.modelName, success=last_status == "success")
405
+ )
406
+ except ollama.ResponseError:
407
+ self.post_message_all(ModelPulled(model_name=job.modelName, success=False))
408
+
409
+ async def do_push(self, job: PushModelJob) -> None:
410
+ """Push a model"""
411
+ try:
412
+ res = dm.push_model(job.modelName)
413
+ last_status = await self.do_progress(job, res)
414
+
415
+ self.post_message_all(
416
+ ModelPushed(model_name=job.modelName, success=last_status == "success")
417
+ )
418
+ except ollama.ResponseError:
419
+ self.post_message_all(ModelPushed(model_name=job.modelName, success=False))
420
+
421
+ @work(group="do_jobs", thread=True)
422
+ async def do_jobs(self) -> None:
423
+ """Pull all requested models in queue"""
424
+ while True:
425
+ try:
426
+ job: QueueJob = self.job_queue.get(block=True, timeout=1)
427
+ if self._exit:
428
+ return
429
+ if not job:
430
+ continue
431
+ self.is_busy = True
432
+ if isinstance(job, PullModelJob):
433
+ await self.do_pull(job)
434
+ elif isinstance(job, PushModelJob):
435
+ await self.do_push(job)
436
+ elif isinstance(job, CopyModelJob):
437
+ await self.do_copy_local_model(job)
438
+ else:
439
+ self.notify(f"Unknown job type {type(job)}", severity="error")
440
+ except Empty:
441
+ if self._exit:
442
+ return
443
+ if self.is_busy:
444
+ self.post_message(LocalModelListRefreshRequested())
445
+ self.is_busy = False
446
+ continue
447
+
448
+ @on(ModelPulled)
449
+ def on_model_pulled(self, msg: ModelPulled) -> None:
450
+ """Model pulled event"""
451
+ if msg.success:
452
+ self.notify(f"Model {msg.model_name} pulled.")
453
+ else:
454
+ self.notify(f"Model {msg.model_name} failed to pull.", severity="error")
455
+
456
+ def action_refresh_models(self):
457
+ """Refresh models action."""
458
+ self.refresh_models()
459
+
460
+ @on(LocalModelListRefreshRequested)
461
+ def on_model_list_refresh_requested(self) -> None:
462
+ """Model refresh request event"""
463
+ if self.is_refreshing:
464
+ self.notify("A model refresh is already in progress. Please wait.")
465
+ return
466
+ self.refresh_models()
467
+
468
+ @work(group="refresh_models", thread=True)
469
+ async def refresh_models(self):
470
+ """Refresh the models."""
471
+ self.is_refreshing = True
472
+ try:
473
+ self.post_message_all(StatusMessage("Local model list refreshing..."))
474
+ dm.refresh_models()
475
+ self.screen.post_message(LocalModelListLoaded())
476
+ finally:
477
+ self.is_refreshing = False
478
+
479
+ @on(LocalModelListLoaded)
480
+ def on_model_data_loaded(self) -> None:
481
+ """Refresh model completed"""
482
+ self.post_message_all(StatusMessage("Local model list refreshed"))
483
+ # self.notify("Local models refreshed.")
484
+
485
+ @on(SiteModelsRefreshRequested)
486
+ def on_site_models_refresh_requested(self, msg: SiteModelsRefreshRequested) -> None:
487
+ """Site model refresh request event"""
488
+ if self.is_refreshing:
489
+ self.notify("A model refresh is already in progress. Please wait.")
490
+ return
491
+ self.refresh_site_models(msg)
492
+
493
+ @on(SiteModelsLoaded)
494
+ def on_site_models_loaded(self, msg: SiteModelsLoaded) -> None:
495
+ """Site model refresh completed"""
496
+ self.notify(f"Site models refreshed for {msg.ollama_namespace or 'models'}")
497
+
498
+ @work(group="refresh_site_model", thread=True)
499
+ async def refresh_site_models(self, msg: SiteModelsRefreshRequested):
500
+ """Refresh the site model."""
501
+ self.is_refreshing = True
502
+ try:
503
+ self.post_message_all(
504
+ StatusMessage(
505
+ f"Site models for {msg.ollama_namespace or 'models'} refreshing... force={msg.force}"
506
+ )
507
+ )
508
+ dm.refresh_site_models(msg.ollama_namespace, msg.force)
509
+ self.screen.post_message(
510
+ SiteModelsLoaded(ollama_namespace=msg.ollama_namespace)
511
+ )
512
+ self.post_message_all(
513
+ StatusMessage(
514
+ f"Site models for {msg.ollama_namespace or 'models'} loaded. force={msg.force}"
515
+ )
516
+ )
517
+
518
+ finally:
519
+ self.is_refreshing = False
520
+
521
+ @work(group="update_ps", thread=True)
522
+ async def update_ps(self) -> None:
523
+ """Update ps msg"""
524
+ was_blank = False
525
+ while self.is_running:
526
+ await asyncio.sleep(1)
527
+ ret = dm.model_ps()
528
+ if not ret:
529
+ if not was_blank:
530
+ self.screen.post_message(PsMessage(msg=""))
531
+ was_blank = True
532
+ continue
533
+ was_blank = False
534
+ info = ret[0]
535
+ self.local_models_screen.post_message(
536
+ PsMessage(
537
+ msg=Text.assemble(
538
+ "Name: ",
539
+ info["name"],
540
+ " Size: ",
541
+ info["size"],
542
+ " Processor: ",
543
+ info["processor"],
544
+ " Until: ",
545
+ info["until"],
546
+ )
547
+ )
548
+ )
549
+ self.post_message_all(StatusMessage(msg="exited..."))
550
+
551
+ def post_message_all(self, msg: Message) -> None:
552
+ """Post a message to all screens"""
553
+ if isinstance(msg, StatusMessage):
554
+ self.log(msg.msg)
555
+ self.last_status = msg.msg
556
+
557
+ if self.local_models_screen:
558
+ self.local_models_screen.post_message(msg)
559
+ if self.site_models_screen:
560
+ self.site_models_screen.post_message(msg)
561
+
562
+ def _log(
563
+ self,
564
+ group: LogGroup,
565
+ verbosity: LogVerbosity,
566
+ _textual_calling_frame: inspect.Traceback,
567
+ *objects: Any,
568
+ **kwargs,
569
+ ) -> None:
570
+ """Write to logs or devtools.
571
+
572
+ Positional args are logged. Keyword args will be prefixed with the key.
573
+
574
+ Example:
575
+ ```python
576
+ data = [1,2,3]
577
+ self.log("Hello, World", state=data)
578
+ self.log(self.tree)
579
+ self.log(locals())
580
+ ```
581
+
582
+ Args:
583
+ verbosity: Verbosity level 0-3.
584
+ """
585
+
586
+ if self.log_screen and self.log_screen.richlog:
587
+ if len(objects) == 1:
588
+ types_to_check = [ConsoleRenderable, RichCast, str]
589
+ renderable: RenderableType | None = None
590
+ if isinstance(objects[0], Columns):
591
+ renderable = objects[0].renderables[0]
592
+ else:
593
+ # make sure we have a renderable type
594
+ for ttc in types_to_check:
595
+ if isinstance(objects[0], ttc):
596
+ renderable = objects[0] # type: ignore
597
+ break
598
+ if renderable:
599
+ # dont log duplicate messages
600
+ if len(self.log_screen.richlog.lines) > 0:
601
+ if (
602
+ Strip.from_lines(
603
+ list(
604
+ Segment.split_lines(self.console.render(renderable))
605
+ )
606
+ )[0].text
607
+ != self.log_screen.richlog.lines[-1].text
608
+ ):
609
+ self.log_screen.richlog.write(renderable)
610
+ return
611
+ else:
612
+ self.log_screen.richlog.write(renderable)
613
+ return
614
+
615
+ super()._log(group, verbosity, _textual_calling_frame, *objects, **kwargs)