glaip-sdk 0.7.7__py3-none-any.whl → 0.7.12__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.
@@ -6,9 +6,11 @@ import logging
6
6
  from enum import Enum
7
7
  from typing import Literal
8
8
 
9
+ from glaip_sdk.cli.account_store import AccountStore, AccountStoreError
9
10
  from glaip_sdk.cli.slash.tui.terminal import TerminalCapabilities
10
11
  from glaip_sdk.cli.slash.tui.theme.catalog import default_theme_name_for_mode, get_builtin_theme
11
12
  from glaip_sdk.cli.slash.tui.theme.tokens import ThemeTokens
13
+ from glaip_sdk.cli.tui_settings import persist_tui_theme
12
14
 
13
15
  logger = logging.getLogger(__name__)
14
16
 
@@ -30,11 +32,13 @@ class ThemeManager:
30
32
  *,
31
33
  mode: ThemeMode | str = ThemeMode.AUTO,
32
34
  theme: str | None = None,
35
+ settings_store: AccountStore | None = None,
33
36
  ) -> None:
34
37
  """Initialize the theme manager."""
35
38
  self._terminal = terminal
36
39
  self._mode = self._coerce_mode(mode)
37
- self._theme = theme
40
+ self._theme = self._normalize_theme_name(theme)
41
+ self._settings_store = settings_store
38
42
 
39
43
  @property
40
44
  def mode(self) -> ThemeMode:
@@ -70,10 +74,12 @@ class ThemeManager:
70
74
  def set_mode(self, mode: ThemeMode | str) -> None:
71
75
  """Set auto/light/dark mode."""
72
76
  self._mode = self._coerce_mode(mode)
77
+ self._persist_preferences()
73
78
 
74
79
  def set_theme(self, theme: str | None) -> None:
75
80
  """Set explicit theme name (or None to use the default)."""
76
- self._theme = theme
81
+ self._theme = self._normalize_theme_name(theme)
82
+ self._persist_preferences()
77
83
 
78
84
  def _coerce_mode(self, mode: ThemeMode | str) -> ThemeMode:
79
85
  """Coerce a mode value to ThemeMode enum, defaulting to AUTO on invalid input."""
@@ -84,3 +90,23 @@ class ThemeManager:
84
90
  except ValueError:
85
91
  logger.warning(f"Invalid theme mode '{mode}', defaulting to AUTO")
86
92
  return ThemeMode.AUTO
93
+
94
+ def _persist_preferences(self) -> None:
95
+ if self._settings_store is None:
96
+ return
97
+ try:
98
+ persist_tui_theme(mode=self._mode.value, name=self._theme, store=self._settings_store)
99
+ except (OSError, AccountStoreError) as exc:
100
+ # Log recoverable errors (permissions, I/O) as warnings
101
+ logger.warning(f"Failed to persist TUI theme preferences: {exc}")
102
+ except Exception as exc:
103
+ # Log unexpected errors at error level for debugging
104
+ logger.error(f"Unexpected error persisting TUI theme preferences: {exc}", exc_info=True)
105
+
106
+ def _normalize_theme_name(self, theme: str | None) -> str | None:
107
+ if not isinstance(theme, str):
108
+ return None
109
+ cleaned = theme.strip()
110
+ if not cleaned or cleaned.lower() == "default":
111
+ return None
112
+ return cleaned
@@ -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")
@@ -0,0 +1,125 @@
1
+ """Typed loader/saver for TUI preferences stored in config.yaml.
2
+
3
+ This module implements the TUI preferences store as defined in
4
+ `specs/architecture/tui-preferences-store/spec.md`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Literal, cast
11
+
12
+ from glaip_sdk.cli.account_store import AccountStore, get_account_store
13
+
14
+ ThemeModeValue = Literal["auto", "light", "dark"]
15
+
16
+ _DEFAULT_THEME_MODE: ThemeModeValue = "auto"
17
+ _DEFAULT_LEADER = "ctrl+x"
18
+ _DEFAULT_MOUSE_CAPTURE = False
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class TUISettings:
23
+ """Resolved TUI preferences from config.yaml."""
24
+
25
+ theme_mode: ThemeModeValue = _DEFAULT_THEME_MODE
26
+ theme_name: str | None = None
27
+ leader: str = _DEFAULT_LEADER
28
+ keybind_overrides: dict[str, str] = field(default_factory=dict)
29
+ mouse_capture: bool = _DEFAULT_MOUSE_CAPTURE
30
+
31
+
32
+ def load_tui_settings(*, store: AccountStore | None = None) -> TUISettings:
33
+ """Load TUI preferences from the CLI config file."""
34
+ store = store or get_account_store()
35
+ config = store.load_config()
36
+
37
+ tui = _as_dict(config.get("tui"))
38
+ theme = _as_dict(tui.get("theme"))
39
+ mode = _coerce_theme_mode(theme.get("mode"))
40
+ name = _normalize_theme_name(theme.get("name"))
41
+
42
+ keybinds = _as_dict(tui.get("keybinds"))
43
+ leader = keybinds.get("leader")
44
+ if not isinstance(leader, str) or not leader.strip():
45
+ leader = _DEFAULT_LEADER
46
+
47
+ overrides = _coerce_keybind_overrides(keybinds.get("overrides"))
48
+
49
+ mouse_capture = tui.get("mouse_capture")
50
+ if not isinstance(mouse_capture, bool):
51
+ mouse_capture = _DEFAULT_MOUSE_CAPTURE
52
+
53
+ return TUISettings(
54
+ theme_mode=mode,
55
+ theme_name=name,
56
+ leader=leader,
57
+ keybind_overrides=overrides,
58
+ mouse_capture=mouse_capture,
59
+ )
60
+
61
+
62
+ def update_tui_settings(patch: dict[str, Any], *, store: AccountStore | None = None) -> None:
63
+ """Update TUI preferences, preserving unrelated config keys."""
64
+ store = store or get_account_store()
65
+ config = store.load_config()
66
+
67
+ existing = _as_dict(config.get("tui"))
68
+ config["tui"] = _merge_dict(existing, patch)
69
+
70
+ store.save_config_updates(config)
71
+
72
+
73
+ def persist_tui_theme(*, mode: ThemeModeValue, name: str | None, store: AccountStore | None = None) -> None:
74
+ """Persist theme preferences in the tui.theme namespace."""
75
+ update_tui_settings(
76
+ {"theme": {"mode": _coerce_theme_mode(mode), "name": _serialize_theme_name(name)}},
77
+ store=store,
78
+ )
79
+
80
+
81
+ def _as_dict(value: Any) -> dict[str, Any]:
82
+ if isinstance(value, dict):
83
+ return dict(value)
84
+ return {}
85
+
86
+
87
+ def _coerce_theme_mode(value: Any) -> ThemeModeValue:
88
+ if isinstance(value, str):
89
+ lowered = value.strip().lower()
90
+ if lowered in {"auto", "light", "dark"}:
91
+ return cast(ThemeModeValue, lowered)
92
+ return _DEFAULT_THEME_MODE
93
+
94
+
95
+ def _normalize_theme_name(value: Any) -> str | None:
96
+ if not isinstance(value, str):
97
+ return None
98
+ cleaned = value.strip()
99
+ if not cleaned or cleaned.lower() == "default":
100
+ return None
101
+ return cleaned
102
+
103
+
104
+ def _serialize_theme_name(name: str | None) -> str:
105
+ if isinstance(name, str):
106
+ cleaned = name.strip()
107
+ if cleaned:
108
+ return cleaned
109
+ return "default"
110
+
111
+
112
+ def _coerce_keybind_overrides(value: Any) -> dict[str, str]:
113
+ if not isinstance(value, dict):
114
+ return {}
115
+ return {key: val for key, val in value.items() if isinstance(key, str) and isinstance(val, str)}
116
+
117
+
118
+ def _merge_dict(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
119
+ merged = dict(base)
120
+ for key, value in patch.items():
121
+ if isinstance(value, dict) and isinstance(merged.get(key), dict):
122
+ merged[key] = _merge_dict(cast(dict[str, Any], merged[key]), value)
123
+ else:
124
+ merged[key] = value
125
+ return merged
@@ -172,6 +172,63 @@ class AgentRunRenderingManager:
172
172
  finished_monotonic = monotonic()
173
173
  return final_text, stats_usage, started_monotonic, finished_monotonic
174
174
 
175
+ async def _consume_event_stream(
176
+ self,
177
+ event_stream: AsyncIterable[dict[str, Any]],
178
+ renderer: RichStreamRenderer,
179
+ final_text: str,
180
+ stats_usage: dict[str, Any],
181
+ meta: dict[str, Any],
182
+ skip_final_render: bool,
183
+ last_rendered_content: str | None,
184
+ controller: Any | None,
185
+ ) -> tuple[str, dict[str, Any], float | None]:
186
+ """Consume event stream and update state.
187
+
188
+ Args:
189
+ event_stream: Async iterable yielding SSE-like event dicts.
190
+ renderer: Renderer to use for displaying events.
191
+ final_text: Current accumulated final text.
192
+ stats_usage: Usage statistics dictionary.
193
+ meta: Metadata dictionary.
194
+ skip_final_render: If True, skip rendering final_response events.
195
+ last_rendered_content: Last rendered content to avoid duplicates.
196
+ controller: Controller instance.
197
+
198
+ Returns:
199
+ Tuple of (final_text, stats_usage, started_monotonic).
200
+ """
201
+ started_monotonic: float | None = None
202
+
203
+ async for event in event_stream:
204
+ if started_monotonic is None:
205
+ started_monotonic = monotonic()
206
+
207
+ parsed_event = self._parse_event(event)
208
+ if parsed_event is None:
209
+ continue
210
+
211
+ final_text, stats_usage = self._handle_parsed_event(
212
+ parsed_event,
213
+ renderer,
214
+ final_text,
215
+ stats_usage,
216
+ meta,
217
+ skip_final_render=skip_final_render,
218
+ last_rendered_content=last_rendered_content,
219
+ )
220
+
221
+ content_str = self._extract_content_string(parsed_event)
222
+ if content_str:
223
+ last_rendered_content = content_str
224
+
225
+ if controller and getattr(controller, "enabled", False):
226
+ controller.poll(renderer)
227
+ if parsed_event and self._is_final_event(parsed_event):
228
+ break
229
+
230
+ return final_text, stats_usage, started_monotonic
231
+
175
232
  async def async_process_stream_events(
176
233
  self,
177
234
  event_stream: AsyncIterable[dict[str, Any]],
@@ -207,35 +264,25 @@ class AgentRunRenderingManager:
207
264
  controller.on_stream_start(renderer)
208
265
 
209
266
  try:
210
- async for event in event_stream:
211
- if started_monotonic is None:
212
- started_monotonic = monotonic()
213
-
214
- # Parse event if needed (handles both raw SSE and pre-parsed dicts)
215
- parsed_event = self._parse_event(event)
216
- if parsed_event is None:
217
- continue
218
-
219
- # Process the event and update accumulators
220
- final_text, stats_usage = self._handle_parsed_event(
221
- parsed_event,
222
- renderer,
223
- final_text,
224
- stats_usage,
225
- meta,
226
- skip_final_render=skip_final_render,
227
- last_rendered_content=last_rendered_content,
228
- )
229
-
230
- # Track last rendered content to avoid duplicates
231
- content_str = self._extract_content_string(parsed_event)
232
- if content_str:
233
- last_rendered_content = content_str
234
-
235
- if controller and getattr(controller, "enabled", False):
236
- controller.poll(renderer)
237
- if parsed_event and self._is_final_event(parsed_event):
238
- break
267
+ final_text, stats_usage, started_monotonic = await self._consume_event_stream(
268
+ event_stream,
269
+ renderer,
270
+ final_text,
271
+ stats_usage,
272
+ meta,
273
+ skip_final_render,
274
+ last_rendered_content,
275
+ controller,
276
+ )
277
+ except Exception as e:
278
+ err_msg = str(e)
279
+ reason = getattr(getattr(e, "result", None), "reason", None)
280
+ if reason:
281
+ final_text = f"⚠️ Guardrail violation: {reason}"
282
+ elif "⚠️ Guardrail violation" in err_msg or "Content blocked by guardrails" in err_msg:
283
+ final_text = err_msg
284
+ else:
285
+ raise e
239
286
  finally:
240
287
  if controller and getattr(controller, "enabled", False):
241
288
  controller.on_stream_complete()