glaip-sdk 0.7.8__py3-none-any.whl → 0.7.10__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.
@@ -19,12 +19,16 @@ from typing import Any
19
19
  from rich.text import Text
20
20
  from textual.app import App, ComposeResult
21
21
  from textual.binding import Binding
22
- from textual.containers import Container, Horizontal
22
+ from textual.containers import Container, Horizontal, Vertical
23
+ from textual.coordinate import Coordinate
23
24
  from textual.reactive import ReactiveError
24
25
  from textual.screen import ModalScreen
25
26
  from textual.widgets import DataTable, Footer, Header, LoadingIndicator, RichLog, Static
26
27
 
28
+ from glaip_sdk.cli.slash.tui.clipboard import ClipboardAdapter
29
+ from glaip_sdk.cli.slash.tui.context import TUIContext
27
30
  from glaip_sdk.cli.slash.tui.loading import hide_loading_indicator, show_loading_indicator
31
+ from glaip_sdk.cli.slash.tui.toast import ClipboardToastMixin, Toast, ToastBus, ToastHandlerMixin
28
32
 
29
33
  logger = logging.getLogger(__name__)
30
34
 
@@ -50,6 +54,7 @@ def run_remote_runs_textual(
50
54
  *,
51
55
  agent_name: str | None = None,
52
56
  agent_id: str | None = None,
57
+ ctx: TUIContext | None = None,
53
58
  ) -> tuple[int, int, int]:
54
59
  """Launch the Textual application and return the final pagination state.
55
60
 
@@ -59,6 +64,7 @@ def run_remote_runs_textual(
59
64
  callbacks: Data provider callback bundle.
60
65
  agent_name: Optional agent name for display purposes.
61
66
  agent_id: Optional agent ID for display purposes.
67
+ ctx: Shared TUI context.
62
68
 
63
69
  Returns:
64
70
  Tuple of (page, limit, cursor_index) after the UI exits.
@@ -69,15 +75,27 @@ def run_remote_runs_textual(
69
75
  callbacks,
70
76
  agent_name=agent_name,
71
77
  agent_id=agent_id,
78
+ ctx=ctx,
72
79
  )
73
80
  app.run()
74
81
  current_page = getattr(app, "current_page", initial_page)
75
82
  return current_page.page, current_page.limit, app.cursor_index
76
83
 
77
84
 
78
- class RunDetailScreen(ModalScreen[None]):
85
+ class RunDetailScreen(ToastHandlerMixin, ClipboardToastMixin, ModalScreen[None]):
79
86
  """Modal screen displaying run metadata and output timeline."""
80
87
 
88
+ CSS = """
89
+ Screen { layout: vertical; layers: base toasts; }
90
+ #toast-container {
91
+ width: 100%;
92
+ height: auto;
93
+ dock: top;
94
+ align: right top;
95
+ layer: toasts;
96
+ }
97
+ """
98
+
81
99
  BINDINGS = [
82
100
  Binding("escape", "dismiss", "Close", priority=True),
83
101
  Binding("q", "dismiss_modal", "Close", priority=True),
@@ -85,14 +103,24 @@ class RunDetailScreen(ModalScreen[None]):
85
103
  Binding("down", "scroll_down", "Down"),
86
104
  Binding("pageup", "page_up", "PgUp"),
87
105
  Binding("pagedown", "page_down", "PgDn"),
106
+ Binding("c", "copy_run_id", "Copy ID"),
107
+ Binding("C", "copy_detail_json", "Copy JSON"),
88
108
  Binding("e", "export_detail", "Export"),
89
109
  ]
90
110
 
91
- def __init__(self, detail: Any, on_export: Callable[[Any], None] | None = None):
111
+ def __init__(
112
+ self,
113
+ detail: Any,
114
+ on_export: Callable[[Any], None] | None = None,
115
+ ctx: TUIContext | None = None,
116
+ ) -> None:
92
117
  """Initialize the run detail screen."""
93
118
  super().__init__()
94
119
  self.detail = detail
95
120
  self._on_export = on_export
121
+ self._ctx = ctx
122
+ self._clipboard: ClipboardAdapter | None = None
123
+ self._local_toasts: ToastBus | None = None
96
124
 
97
125
  def compose(self) -> ComposeResult:
98
126
  """Render metadata and events."""
@@ -116,14 +144,17 @@ class RunDetailScreen(ModalScreen[None]):
116
144
  duration = self.detail.duration_formatted() if getattr(self.detail, "duration_formatted", None) else None
117
145
  add_meta("Duration", duration, "bold")
118
146
 
119
- yield Container(
147
+ main_content = Vertical(
120
148
  Static(meta_text, id="detail-meta"),
121
149
  RichLog(id="detail-events", wrap=False),
122
150
  )
151
+ yield main_content
152
+ yield Container(Toast(), id="toast-container")
123
153
  yield Footer()
124
154
 
125
155
  def on_mount(self) -> None:
126
156
  """Populate and focus the log."""
157
+ self._ensure_toast_bus()
127
158
  log = self.query_one("#detail-events", RichLog)
128
159
  log.can_focus = True
129
160
  log.write(Text("Events", style="bold"))
@@ -149,6 +180,61 @@ class RunDetailScreen(ModalScreen[None]):
149
180
  def _log(self) -> RichLog:
150
181
  return self.query_one("#detail-events", RichLog)
151
182
 
183
+ def action_copy_run_id(self) -> None:
184
+ """Copy the run id to the clipboard."""
185
+ run_id = getattr(self.detail, "id", None)
186
+ if not run_id:
187
+ self._announce_status("Run ID unavailable.")
188
+ return
189
+ self._copy_to_clipboard(str(run_id), label="Run ID")
190
+
191
+ def action_copy_detail_json(self) -> None:
192
+ """Copy the run detail JSON to the clipboard."""
193
+ payload = self._detail_json_payload()
194
+ if payload is None:
195
+ return
196
+ self._copy_to_clipboard(payload, label="Run JSON")
197
+
198
+ def _detail_json_payload(self) -> str | None:
199
+ detail = self.detail
200
+ if detail is None:
201
+ self._announce_status("Run detail unavailable.")
202
+ return None
203
+ if isinstance(detail, str):
204
+ return detail
205
+ if isinstance(detail, dict):
206
+ payload = detail
207
+ elif hasattr(detail, "model_dump"):
208
+ payload = detail.model_dump(mode="json")
209
+ elif hasattr(detail, "dict"):
210
+ payload = detail.dict()
211
+ else:
212
+ payload = getattr(detail, "__dict__", {"value": detail})
213
+ try:
214
+ return json.dumps(payload, indent=2, ensure_ascii=False, default=str)
215
+ except Exception as exc:
216
+ self._announce_status(f"Failed to serialize run detail: {exc}")
217
+ return None
218
+
219
+ def _append_copy_fallback(self, text: str) -> None:
220
+ try:
221
+ log = self._log()
222
+ except Exception:
223
+ self._announce_status(text)
224
+ return
225
+ log.write(Text(text))
226
+ log.write(Text(""))
227
+
228
+ def _ensure_toast_bus(self) -> None:
229
+ """Ensure toast bus is initialized and connected to message handler."""
230
+ if self._local_toasts is not None:
231
+ return # pragma: no cover - early return when already initialized
232
+
233
+ def _notify(m: ToastBus.Changed) -> None:
234
+ self.post_message(m)
235
+
236
+ self._local_toasts = ToastBus(on_change=_notify)
237
+
152
238
  @staticmethod
153
239
  def _status_style(status: str | None) -> str:
154
240
  """Return a Rich style name for the status pill."""
@@ -220,11 +306,18 @@ class RunDetailScreen(ModalScreen[None]):
220
306
  update_status(message, append=True)
221
307
 
222
308
 
223
- class RemoteRunsTextualApp(App[None]):
309
+ class RemoteRunsTextualApp(ToastHandlerMixin, App[None]):
224
310
  """Textual application for browsing remote runs."""
225
311
 
226
312
  CSS = f"""
227
- Screen {{ layout: vertical; }}
313
+ Screen {{ layout: vertical; layers: base toasts; }}
314
+ #toast-container {{
315
+ width: 100%;
316
+ height: auto;
317
+ dock: top;
318
+ align: right top;
319
+ layer: toasts;
320
+ }}
228
321
  #status-bar {{ height: 3; padding: 0 1; }}
229
322
  #agent-context {{ min-width: 25; padding-right: 1; }}
230
323
  #{RUNS_LOADING_ID} {{ width: 8; }}
@@ -247,6 +340,7 @@ class RemoteRunsTextualApp(App[None]):
247
340
  *,
248
341
  agent_name: str | None = None,
249
342
  agent_id: str | None = None,
343
+ ctx: TUIContext | None = None,
250
344
  ):
251
345
  """Initialize the remote runs Textual application.
252
346
 
@@ -256,6 +350,7 @@ class RemoteRunsTextualApp(App[None]):
256
350
  callbacks: Callback bundle for data operations.
257
351
  agent_name: Optional agent name for display purposes.
258
352
  agent_id: Optional agent ID for display purposes.
353
+ ctx: Shared TUI context.
259
354
  """
260
355
  super().__init__()
261
356
  self.current_page = initial_page
@@ -265,6 +360,7 @@ class RemoteRunsTextualApp(App[None]):
265
360
  self.current_rows = initial_page.data[:]
266
361
  self.agent_name = (agent_name or "").strip()
267
362
  self.agent_id = (agent_id or "").strip()
363
+ self._ctx = ctx
268
364
  self._active_export_tasks: set[asyncio.Task[None]] = set()
269
365
  self._page_loader_task: asyncio.Task[Any] | None = None
270
366
  self._detail_loader_task: asyncio.Task[Any] | None = None
@@ -273,9 +369,10 @@ class RemoteRunsTextualApp(App[None]):
273
369
  def compose(self) -> ComposeResult:
274
370
  """Build layout."""
275
371
  yield Header()
276
- table = DataTable(id=RUNS_TABLE_ID)
277
- table.cursor_type = "row"
278
- table.add_columns(
372
+ yield Container(Toast(), id="toast-container")
373
+ table = DataTable(id=RUNS_TABLE_ID) # pragma: no cover - mocked in tests
374
+ table.cursor_type = "row" # pragma: no cover - mocked in tests
375
+ table.add_columns( # pragma: no cover - mocked in tests
279
376
  "Run UUID",
280
377
  "Type",
281
378
  "Status",
@@ -292,8 +389,18 @@ class RemoteRunsTextualApp(App[None]):
292
389
  )
293
390
  yield Footer() # pragma: no cover - interactive UI, tested via integration
294
391
 
392
+ def _ensure_toast_bus(self) -> None:
393
+ if self._ctx is None or self._ctx.toasts is not None:
394
+ return
395
+
396
+ def _notify(m: ToastBus.Changed) -> None:
397
+ self.post_message(m)
398
+
399
+ self._ctx.toasts = ToastBus(on_change=_notify)
400
+
295
401
  def on_mount(self) -> None:
296
402
  """Render the initial page."""
403
+ self._ensure_toast_bus()
297
404
  self._hide_loading()
298
405
  self._render_page(self.current_page)
299
406
 
@@ -315,7 +422,7 @@ class RemoteRunsTextualApp(App[None]):
315
422
  if self.current_rows:
316
423
  self.cursor_index = max(0, min(self.cursor_index, len(self.current_rows) - 1))
317
424
  table.focus()
318
- table.cursor_coordinate = (self.cursor_index, 0)
425
+ table.cursor_coordinate = Coordinate(self.cursor_index, 0)
319
426
  self.current_page = runs_page
320
427
  total_pages = max(1, (runs_page.total + runs_page.limit - 1) // runs_page.limit)
321
428
  agent_display = self.agent_name or "Runs"
@@ -554,8 +661,8 @@ class RemoteRunsTextualApp(App[None]):
554
661
  if detail is None:
555
662
  self._update_status("Failed to load run detail.", append=True)
556
663
  return
557
- self.push_screen(RunDetailScreen(detail, on_export=self.queue_export_from_detail))
558
- self._update_status("Detail view: ↑/↓ scroll · PgUp/PgDn · q/Esc close · e export")
664
+ self.push_screen(RunDetailScreen(detail, on_export=self.queue_export_from_detail, ctx=self._ctx))
665
+ self._update_status("Detail view: ↑/↓ scroll · PgUp/PgDn · q/Esc close · c copy ID · C copy JSON · e export")
559
666
 
560
667
  def queue_export_from_detail(self, detail: Any) -> None:
561
668
  """Start an export from the detail modal."""
@@ -1,18 +1,20 @@
1
- """Toast notification helpers for Textual TUIs.
2
-
3
- Authors:
4
- Raymond Christopher (raymond.christopher@gdplabs.id)
5
- """
1
+ """Toast widgets and state management for the TUI."""
6
2
 
7
3
  from __future__ import annotations
8
4
 
9
5
  import asyncio
6
+ from collections.abc import Callable
10
7
  from dataclasses import dataclass
11
8
  from enum import Enum
9
+ from typing import Any, cast
10
+
11
+ from rich.text import Text
12
+ from textual.message import Message
13
+ from textual.widgets import Static
12
14
 
13
15
 
14
16
  class ToastVariant(str, Enum):
15
- """Toast message variant."""
17
+ """Toast message variant for styling and behavior."""
16
18
 
17
19
  INFO = "info"
18
20
  SUCCESS = "success"
@@ -30,7 +32,7 @@ DEFAULT_TOAST_DURATIONS_SECONDS: dict[ToastVariant, float] = {
30
32
 
31
33
  @dataclass(frozen=True, slots=True)
32
34
  class ToastState:
33
- """Immutable toast payload."""
35
+ """Immutable toast notification state."""
34
36
 
35
37
  message: str
36
38
  variant: ToastVariant
@@ -38,16 +40,25 @@ class ToastState:
38
40
 
39
41
 
40
42
  class ToastBus:
41
- """Single-toast state holder with auto-dismiss."""
43
+ """Toast state manager with auto-dismiss functionality."""
42
44
 
43
- def __init__(self) -> None:
44
- """Initialize the bus."""
45
+ class Changed(Message):
46
+ """Message sent when toast state changes."""
47
+
48
+ def __init__(self, state: ToastState | None) -> None:
49
+ """Initialize the changed message with new toast state."""
50
+ super().__init__()
51
+ self.state = state
52
+
53
+ def __init__(self, on_change: Callable[[ToastBus.Changed], None] | None = None) -> None:
54
+ """Initialize the toast bus with optional change callback."""
45
55
  self._state: ToastState | None = None
46
56
  self._dismiss_task: asyncio.Task[None] | None = None
57
+ self._on_change = on_change
47
58
 
48
59
  @property
49
60
  def state(self) -> ToastState | None:
50
- """Return the current toast state."""
61
+ """Return the current toast state, or None if no toast is shown."""
51
62
  return self._state
52
63
 
53
64
  def show(
@@ -57,7 +68,14 @@ class ToastBus:
57
68
  *,
58
69
  duration_seconds: float | None = None,
59
70
  ) -> None:
60
- """Set toast state and schedule auto-dismiss."""
71
+ """Show a toast notification with the given message and variant.
72
+
73
+ Args:
74
+ message: The message to display in the toast.
75
+ variant: The visual variant of the toast (INFO, SUCCESS, WARNING, ERROR).
76
+ duration_seconds: Optional custom duration in seconds. If None, uses default
77
+ duration for the variant (2s for SUCCESS, 3s for INFO/WARNING, 5s for ERROR).
78
+ """
61
79
  resolved_variant = self._coerce_variant(variant)
62
80
  resolved_duration = (
63
81
  DEFAULT_TOAST_DURATIONS_SECONDS[resolved_variant] if duration_seconds is None else float(duration_seconds)
@@ -80,23 +98,26 @@ class ToastBus:
80
98
  ) from None
81
99
 
82
100
  self._dismiss_task = loop.create_task(self._auto_dismiss(resolved_duration))
101
+ self._notify_changed()
83
102
 
84
103
  def clear(self) -> None:
85
- """Clear the current toast."""
104
+ """Clear the current toast notification immediately."""
86
105
  self._cancel_dismiss_task()
87
106
  self._state = None
107
+ self._notify_changed()
88
108
 
89
109
  def copy_success(self, label: str | None = None) -> None:
90
- """Show clipboard success toast."""
110
+ """Show a success toast for clipboard copy operations.
111
+
112
+ Args:
113
+ label: Optional label for what was copied (e.g., "Run ID", "JSON").
114
+ """
91
115
  message = "Copied to clipboard" if not label else f"Copied {label} to clipboard"
92
116
  self.show(message=message, variant=ToastVariant.SUCCESS)
93
117
 
94
118
  def copy_failed(self) -> None:
95
- """Show clipboard failure toast."""
96
- self.show(
97
- message="Clipboard unavailable. Text printed below",
98
- variant=ToastVariant.WARNING,
99
- )
119
+ """Show a warning toast when clipboard copy fails."""
120
+ self.show(message="Clipboard unavailable. Text printed below.", variant=ToastVariant.WARNING)
100
121
 
101
122
  def _coerce_variant(self, variant: ToastVariant | str) -> ToastVariant:
102
123
  if isinstance(variant, ToastVariant):
@@ -121,3 +142,233 @@ class ToastBus:
121
142
 
122
143
  self._state = None
123
144
  self._dismiss_task = None
145
+ self._notify_changed()
146
+
147
+ def _notify_changed(self) -> None:
148
+ if self._on_change:
149
+ self._on_change(ToastBus.Changed(self._state))
150
+
151
+
152
+ class ToastHandlerMixin:
153
+ """Mixin providing common toast handling functionality.
154
+
155
+ Classes that inherit from this mixin can handle ToastBus.Changed messages
156
+ by automatically updating all Toast widgets in the component tree.
157
+ """
158
+
159
+ def on_toast_bus_changed(self, message: ToastBus.Changed) -> None:
160
+ """Refresh the toast widget when the toast bus updates.
161
+
162
+ Args:
163
+ message: The toast bus changed message containing the new state.
164
+ """
165
+ try:
166
+ for toast in self.query(Toast):
167
+ toast.update_state(message.state)
168
+ except Exception:
169
+ pass
170
+
171
+
172
+ class ClipboardToastMixin:
173
+ """Mixin providing clipboard and toast orchestration functionality.
174
+
175
+ Classes that inherit from this mixin get shared clipboard adapter selection,
176
+ OSC52 writer setup, toast bus lookup, and copy-success/failure orchestration.
177
+ This consolidates duplicate clipboard/toast logic across TUI apps.
178
+
179
+ Expected attributes:
180
+ _ctx: TUIContext | None - Shared TUI context (optional)
181
+ _clipboard: ClipboardAdapter | None - Cached clipboard adapter (optional)
182
+ _local_toasts: ToastBus | None - Local toast bus instance (optional)
183
+ """
184
+
185
+ def _clipboard_adapter(self) -> Any: # ClipboardAdapter
186
+ """Get or create a clipboard adapter instance.
187
+
188
+ Returns:
189
+ ClipboardAdapter instance, preferring context's adapter if available.
190
+ """
191
+ # Import here to avoid circular dependency
192
+ from glaip_sdk.cli.slash.tui.clipboard import ClipboardAdapter # noqa: PLC0415
193
+
194
+ ctx = getattr(self, "_ctx", None)
195
+ clipboard = getattr(self, "_clipboard", None)
196
+
197
+ if ctx is not None and ctx.clipboard is not None:
198
+ return cast(ClipboardAdapter, ctx.clipboard)
199
+ if clipboard is not None:
200
+ return clipboard
201
+
202
+ adapter = ClipboardAdapter(terminal=ctx.terminal if ctx else None)
203
+ if ctx is not None:
204
+ ctx.clipboard = adapter
205
+ else:
206
+ self._clipboard = adapter
207
+ return adapter
208
+
209
+ def _osc52_writer(self) -> Callable[[str], Any] | None:
210
+ """Get an OSC52 writer function if console output is available.
211
+
212
+ Returns:
213
+ Writer function that writes OSC52 sequences to console output, or None.
214
+ """
215
+ try:
216
+ # Try self.app.console first (for Screen subclasses)
217
+ if hasattr(self, "app") and hasattr(self.app, "console"):
218
+ console = self.app.console
219
+ # Fall back to self.console (for App subclasses)
220
+ else:
221
+ console = getattr(self, "console", None)
222
+ except Exception:
223
+ return None
224
+
225
+ if console is None:
226
+ return None
227
+
228
+ output = getattr(console, "file", None)
229
+ if output is None:
230
+ return None
231
+
232
+ def _write(sequence: str, _output: Any = output) -> None:
233
+ _output.write(sequence)
234
+ _output.flush()
235
+
236
+ return _write
237
+
238
+ def _toast_bus(self) -> ToastBus | None:
239
+ """Get the toast bus instance.
240
+
241
+ Returns:
242
+ ToastBus instance, preferring context's bus if available, or None.
243
+ """
244
+ local_toasts = getattr(self, "_local_toasts", None)
245
+ ctx = getattr(self, "_ctx", None)
246
+
247
+ if local_toasts is not None:
248
+ return local_toasts
249
+ if ctx is not None and ctx.toasts is not None:
250
+ return ctx.toasts
251
+ return None
252
+
253
+ def _copy_to_clipboard(self, text: str, *, label: str | None = None) -> None:
254
+ """Copy text to clipboard and show toast notification.
255
+
256
+ Args:
257
+ text: The text to copy to clipboard.
258
+ label: Optional label for what was copied (e.g., "Run ID", "JSON").
259
+ """
260
+ adapter = self._clipboard_adapter()
261
+ writer = self._osc52_writer()
262
+ if writer:
263
+ result = adapter.copy(text, writer=writer)
264
+ else:
265
+ result = adapter.copy(text)
266
+
267
+ toasts = self._toast_bus()
268
+ if result.success:
269
+ if toasts:
270
+ toasts.copy_success(label)
271
+ else:
272
+ # Fallback to status announcement if toast bus unavailable
273
+ if hasattr(self, "_announce_status"):
274
+ if label:
275
+ self._announce_status(f"Copied {label} to clipboard.")
276
+ else:
277
+ self._announce_status("Copied to clipboard.")
278
+ return
279
+
280
+ # Copy failed
281
+ if toasts:
282
+ toasts.copy_failed()
283
+ else:
284
+ # Fallback to status announcement if toast bus unavailable
285
+ if hasattr(self, "_announce_status"):
286
+ self._announce_status("Clipboard unavailable. Text printed below.")
287
+
288
+ # Append fallback text output
289
+ if hasattr(self, "_append_copy_fallback"):
290
+ self._append_copy_fallback(text)
291
+
292
+
293
+ class Toast(Static):
294
+ """A Textual widget that displays toast notifications at the top-right of the screen.
295
+
296
+ The Toast widget is updated via `update_state()` calls from message handlers
297
+ (e.g., `on_toast_bus_changed`). The widget does not auto-subscribe to ToastBus
298
+ state changes; the app must call `update_state()` when toast state changes.
299
+ """
300
+
301
+ DEFAULT_CSS = """
302
+ #toast-container {
303
+ width: 100%;
304
+ height: auto;
305
+ dock: top;
306
+ align: right top;
307
+ }
308
+
309
+ Toast {
310
+ width: auto;
311
+ min-width: 20;
312
+ max-width: 40;
313
+ height: auto;
314
+ padding: 0 1;
315
+ margin: 1 2;
316
+ background: $surface;
317
+ color: $text;
318
+ border: solid $primary;
319
+ display: none;
320
+ }
321
+
322
+ Toast.visible {
323
+ display: block;
324
+ }
325
+
326
+ Toast.info {
327
+ border: solid $accent;
328
+ }
329
+
330
+ Toast.success {
331
+ border: solid $success;
332
+ }
333
+
334
+ Toast.warning {
335
+ border: solid $warning;
336
+ }
337
+
338
+ Toast.error {
339
+ border: solid $error;
340
+ }
341
+ """
342
+
343
+ def __init__(self) -> None:
344
+ """Initialize the Toast widget.
345
+
346
+ The widget is updated via `update_state()` calls from message handlers
347
+ (e.g., `on_toast_bus_changed`). The widget does not auto-subscribe to
348
+ a ToastBus; the app must call `update_state()` when toast state changes.
349
+ """
350
+ super().__init__("")
351
+
352
+ def update_state(self, state: ToastState | None) -> None:
353
+ """Update the toast display based on the provided state.
354
+
355
+ Args:
356
+ state: The toast state to display, or None to hide the toast.
357
+ """
358
+ if not state:
359
+ self.remove_class("visible")
360
+ return
361
+
362
+ icon = "ℹ️"
363
+ if state.variant == ToastVariant.SUCCESS:
364
+ icon = "✅"
365
+ elif state.variant == ToastVariant.WARNING:
366
+ icon = "⚠️"
367
+ elif state.variant == ToastVariant.ERROR:
368
+ icon = "❌"
369
+
370
+ self.update(Text.assemble((f"{icon} ", "bold"), state.message))
371
+
372
+ self.remove_class("info", "success", "warning", "error")
373
+ self.add_class(state.variant.value)
374
+ self.add_class("visible")